@ovencord/ws 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/README.md +262 -0
- package/package.json +57 -0
- package/src/index.ts +23 -0
- package/src/strategies/context/IContextFetchingStrategy.ts +57 -0
- package/src/strategies/context/SimpleContextFetchingStrategy.ts +40 -0
- package/src/strategies/context/WorkerContextFetchingStrategy.ts +95 -0
- package/src/strategies/sharding/IShardingStrategy.ts +30 -0
- package/src/strategies/sharding/SimpleShardingStrategy.ts +84 -0
- package/src/strategies/sharding/WorkerShardingStrategy.ts +255 -0
- package/src/strategies/sharding/defaultWorker.ts +4 -0
- package/src/throttling/IIdentifyThrottler.ts +11 -0
- package/src/throttling/SimpleIdentifyThrottler.ts +49 -0
- package/src/utils/BunCompression.ts +144 -0
- package/src/utils/WorkerBootstrapper.ts +65 -0
- package/src/utils/constants.ts +85 -0
- package/src/ws/WebSocketManager.ts +396 -0
- package/src/ws/WebSocketShard.ts +876 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Collection } from '@ovencord/collection';
|
|
2
|
+
import type { SessionInfo } from '../../ws/WebSocketManager.js';
|
|
3
|
+
import {
|
|
4
|
+
WorkerReceivePayloadOp,
|
|
5
|
+
WorkerSendPayloadOp,
|
|
6
|
+
type WorkerReceivePayload,
|
|
7
|
+
type WorkerSendPayload,
|
|
8
|
+
} from '../sharding/WorkerShardingStrategy.js';
|
|
9
|
+
import type { FetchingStrategyOptions, IContextFetchingStrategy } from './IContextFetchingStrategy.js';
|
|
10
|
+
|
|
11
|
+
// Define the global self explicitly for TypeScript as a Worker
|
|
12
|
+
declare const self: Worker;
|
|
13
|
+
|
|
14
|
+
export class WorkerContextFetchingStrategy implements IContextFetchingStrategy {
|
|
15
|
+
private readonly sessionPromises = new Collection<number, (session: SessionInfo | null) => void>();
|
|
16
|
+
|
|
17
|
+
private readonly waitForIdentifyPromises = new Collection<
|
|
18
|
+
number,
|
|
19
|
+
{ reject(error: unknown): void; resolve(): void; signal: AbortSignal }
|
|
20
|
+
>();
|
|
21
|
+
|
|
22
|
+
public constructor(public readonly options: FetchingStrategyOptions) {
|
|
23
|
+
self.addEventListener('message', (event: MessageEvent) => {
|
|
24
|
+
const payload = event.data as WorkerSendPayload;
|
|
25
|
+
if (payload.op === WorkerSendPayloadOp.SessionInfoResponse) {
|
|
26
|
+
this.sessionPromises.get(payload.d.nonce)?.(payload.d.session);
|
|
27
|
+
this.sessionPromises.delete(payload.d.nonce);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (payload.op === WorkerSendPayloadOp.ShardIdentifyResponse) {
|
|
31
|
+
const promise = this.waitForIdentifyPromises.get(payload.d.nonce);
|
|
32
|
+
if (payload.d.ok) {
|
|
33
|
+
promise?.resolve();
|
|
34
|
+
} else {
|
|
35
|
+
// We need to make sure we reject with an abort error
|
|
36
|
+
promise?.reject(promise.signal.reason);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.waitForIdentifyPromises.delete(payload.d.nonce);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async retrieveSessionInfo(shardId: number): Promise<SessionInfo | null> {
|
|
45
|
+
const nonce = Math.random();
|
|
46
|
+
const payload: WorkerReceivePayload = {
|
|
47
|
+
op: WorkerReceivePayloadOp.RetrieveSessionInfo,
|
|
48
|
+
d: { shardId, nonce }
|
|
49
|
+
} as any;
|
|
50
|
+
|
|
51
|
+
const promise = new Promise<SessionInfo | null>((resolve) => this.sessionPromises.set(nonce, resolve));
|
|
52
|
+
self.postMessage(payload);
|
|
53
|
+
return promise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public updateSessionInfo(shardId: number, sessionInfo: SessionInfo | null) {
|
|
57
|
+
const payload: WorkerReceivePayload = {
|
|
58
|
+
op: WorkerReceivePayloadOp.UpdateSessionInfo,
|
|
59
|
+
d: { shardId, session: sessionInfo }
|
|
60
|
+
} as any;
|
|
61
|
+
self.postMessage(payload);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public async waitForIdentify(shardId: number, signal: AbortSignal): Promise<void> {
|
|
65
|
+
const nonce = Math.random();
|
|
66
|
+
|
|
67
|
+
const payload: WorkerReceivePayload = {
|
|
68
|
+
op: WorkerReceivePayloadOp.WaitForIdentify,
|
|
69
|
+
d: { nonce, shardId }
|
|
70
|
+
} as any;
|
|
71
|
+
const promise = new Promise<void>((resolve, reject) =>
|
|
72
|
+
|
|
73
|
+
this.waitForIdentifyPromises.set(nonce, { signal, resolve, reject }),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
self.postMessage(payload);
|
|
77
|
+
|
|
78
|
+
const listener = () => {
|
|
79
|
+
const payload: WorkerReceivePayload = {
|
|
80
|
+
op: WorkerReceivePayloadOp.CancelIdentify,
|
|
81
|
+
d: { nonce }
|
|
82
|
+
} as any;
|
|
83
|
+
|
|
84
|
+
self.postMessage(payload);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
signal.addEventListener('abort', listener);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await promise;
|
|
91
|
+
} finally {
|
|
92
|
+
signal.removeEventListener('abort', listener);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Collection } from '@ovencord/collection';
|
|
2
|
+
import type { Awaitable } from '@ovencord/util';
|
|
3
|
+
import type { GatewaySendPayload } from 'discord-api-types/v10';
|
|
4
|
+
import type { WebSocketShardDestroyOptions, WebSocketShardStatus } from '../../ws/WebSocketShard.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Strategies responsible for spawning, initializing connections, destroying shards, and relaying events
|
|
8
|
+
*/
|
|
9
|
+
export interface IShardingStrategy {
|
|
10
|
+
/**
|
|
11
|
+
* Initializes all the shards
|
|
12
|
+
*/
|
|
13
|
+
connect(): Awaitable<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Destroys all the shards
|
|
16
|
+
*/
|
|
17
|
+
destroy(options?: Omit<WebSocketShardDestroyOptions, 'recover'>): Awaitable<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Fetches the status of all the shards
|
|
20
|
+
*/
|
|
21
|
+
fetchStatus(): Awaitable<Collection<number, WebSocketShardStatus>>;
|
|
22
|
+
/**
|
|
23
|
+
* Sends a payload to a shard
|
|
24
|
+
*/
|
|
25
|
+
send(shardId: number, payload: GatewaySendPayload): Awaitable<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Spawns all the shards
|
|
28
|
+
*/
|
|
29
|
+
spawn(shardIds: number[]): Awaitable<void>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Collection } from '@ovencord/collection';
|
|
2
|
+
import type { GatewaySendPayload } from 'discord-api-types/v10';
|
|
3
|
+
import type { WebSocketManager } from '../../ws/WebSocketManager.js';
|
|
4
|
+
import { WebSocketShard, WebSocketShardEvents, type WebSocketShardDestroyOptions } from '../../ws/WebSocketShard.js';
|
|
5
|
+
import { managerToFetchingStrategyOptions } from '../context/IContextFetchingStrategy.js';
|
|
6
|
+
import { SimpleContextFetchingStrategy } from '../context/SimpleContextFetchingStrategy.js';
|
|
7
|
+
import type { IShardingStrategy } from './IShardingStrategy.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Simple strategy that just spawns shards in the current process
|
|
11
|
+
*/
|
|
12
|
+
export class SimpleShardingStrategy implements IShardingStrategy {
|
|
13
|
+
private readonly manager: WebSocketManager;
|
|
14
|
+
|
|
15
|
+
private readonly shards = new Collection<number, WebSocketShard>();
|
|
16
|
+
|
|
17
|
+
public constructor(manager: WebSocketManager) {
|
|
18
|
+
this.manager = manager;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* {@inheritDoc IShardingStrategy.spawn}
|
|
23
|
+
*/
|
|
24
|
+
public async spawn(shardIds: number[]) {
|
|
25
|
+
const strategyOptions = await managerToFetchingStrategyOptions(this.manager);
|
|
26
|
+
|
|
27
|
+
for (const shardId of shardIds) {
|
|
28
|
+
const strategy = new SimpleContextFetchingStrategy(this.manager, strategyOptions);
|
|
29
|
+
const shard = new WebSocketShard(strategy, shardId);
|
|
30
|
+
|
|
31
|
+
for (const event of Object.values(WebSocketShardEvents)) {
|
|
32
|
+
shard.on(event, (...args) => this.manager.emit(event, ...args, shardId));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.shards.set(shardId, shard);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* {@inheritDoc IShardingStrategy.connect}
|
|
41
|
+
*/
|
|
42
|
+
public async connect() {
|
|
43
|
+
const promises = [];
|
|
44
|
+
|
|
45
|
+
for (const shard of this.shards.values()) {
|
|
46
|
+
promises.push(shard.connect());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await Promise.all(promises);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* {@inheritDoc IShardingStrategy.destroy}
|
|
54
|
+
*/
|
|
55
|
+
public async destroy(options?: Omit<WebSocketShardDestroyOptions, 'recover'>) {
|
|
56
|
+
const promises = [];
|
|
57
|
+
|
|
58
|
+
for (const shard of this.shards.values()) {
|
|
59
|
+
promises.push(shard.destroy(options));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await Promise.all(promises);
|
|
63
|
+
this.shards.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* {@inheritDoc IShardingStrategy.send}
|
|
68
|
+
*/
|
|
69
|
+
public async send(shardId: number, payload: GatewaySendPayload) {
|
|
70
|
+
const shard = this.shards.get(shardId);
|
|
71
|
+
if (!shard) {
|
|
72
|
+
throw new RangeError(`Shard ${shardId} not found`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return shard.send(payload);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* {@inheritDoc IShardingStrategy.fetchStatus}
|
|
80
|
+
*/
|
|
81
|
+
public async fetchStatus() {
|
|
82
|
+
return this.shards.mapValues((shard) => shard.status);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { Collection } from '@ovencord/collection';
|
|
2
|
+
import type { GatewaySendPayload } from 'discord-api-types/v10';
|
|
3
|
+
import type { SessionInfo, WebSocketManager } from '../../ws/WebSocketManager.js';
|
|
4
|
+
import { IShardingStrategy } from './IShardingStrategy.js';
|
|
5
|
+
import { WebSocketShardStatus } from '../../ws/WebSocketShard.js';
|
|
6
|
+
|
|
7
|
+
export interface WorkerShardingStrategyOptions {
|
|
8
|
+
shardCount: number;
|
|
9
|
+
workerPath: string;
|
|
10
|
+
workerData?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class WorkerShardingStrategy implements IShardingStrategy {
|
|
14
|
+
private readonly manager: WebSocketManager;
|
|
15
|
+
|
|
16
|
+
private readonly options: WorkerShardingStrategyOptions;
|
|
17
|
+
|
|
18
|
+
private readonly workers = new Collection<number, Worker>();
|
|
19
|
+
|
|
20
|
+
public constructor(manager: WebSocketManager, options: WorkerShardingStrategyOptions) {
|
|
21
|
+
this.manager = manager;
|
|
22
|
+
this.options = options;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
public async spawn(shardIds: number[]) {
|
|
27
|
+
for (const shardId of shardIds) {
|
|
28
|
+
const worker = new Worker(this.options.workerPath, {
|
|
29
|
+
// Bun workers do not support workerData in the constructor like Node.js
|
|
30
|
+
// We pass it via postMessage immediately after creation
|
|
31
|
+
} as WorkerOptions);
|
|
32
|
+
|
|
33
|
+
// Initialize worker with data
|
|
34
|
+
worker.postMessage({
|
|
35
|
+
op: 'initial_data',
|
|
36
|
+
data: {
|
|
37
|
+
...(this.options.workerData as object),
|
|
38
|
+
shardId,
|
|
39
|
+
shardCount: this.options.shardCount,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
worker.addEventListener('message', (event) => {
|
|
44
|
+
this.onMessage(shardId, event.data);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
worker.addEventListener('error', (err) => {
|
|
48
|
+
console.error(`Worker for shard ${shardId} encountered an error:`, err);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this.workers.set(shardId, worker);
|
|
52
|
+
await this.waitForReady(shardId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public async connect() {
|
|
57
|
+
const promises = [];
|
|
58
|
+
|
|
59
|
+
for (const [shardId, worker] of this.workers) {
|
|
60
|
+
const payload: WorkerSendPayload = {
|
|
61
|
+
op: WorkerSendPayloadOp.Connect,
|
|
62
|
+
d: { shardId },
|
|
63
|
+
};
|
|
64
|
+
worker.postMessage(payload);
|
|
65
|
+
promises.push(this.waitForReady(shardId));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await Promise.all(promises);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public destroy(options?: { code?: number; reason?: string }) {
|
|
72
|
+
for (const worker of this.workers.values()) {
|
|
73
|
+
const payload: WorkerSendPayload = {
|
|
74
|
+
op: WorkerSendPayloadOp.Destroy,
|
|
75
|
+
d: { shardId: 0, options }, // shardId is ignored for destroy usually, or broadcast
|
|
76
|
+
};
|
|
77
|
+
worker.postMessage(payload);
|
|
78
|
+
// In Bun/Web Workers, we might want to terminate() if we are destroying everything
|
|
79
|
+
worker.terminate();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public send(shardId: number, payload: GatewaySendPayload) {
|
|
84
|
+
const worker = this.workers.get(shardId);
|
|
85
|
+
if (!worker) return;
|
|
86
|
+
|
|
87
|
+
const workerPayload: WorkerSendPayload = {
|
|
88
|
+
op: WorkerSendPayloadOp.Send,
|
|
89
|
+
d: { shardId, payload },
|
|
90
|
+
};
|
|
91
|
+
worker.postMessage(workerPayload);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public async fetchSessionInfo(shardId: number): Promise<SessionInfo | null> {
|
|
95
|
+
const worker = this.workers.get(shardId);
|
|
96
|
+
if (!worker) return null;
|
|
97
|
+
|
|
98
|
+
const nonce = Math.random();
|
|
99
|
+
const payload: WorkerSendPayload = {
|
|
100
|
+
op: WorkerSendPayloadOp.FetchSessionInfo,
|
|
101
|
+
d: { shardId, nonce },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// We need a way to wait for response.
|
|
105
|
+
// This is tricky with raw event listeners without a request/response correlation helper.
|
|
106
|
+
// For simplicity in this migration, we assume a fire-and-forget or need to implement a one-off listener.
|
|
107
|
+
// Since we are moving to Bun, we can use a temporary promise.
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const listener = (event: MessageEvent) => {
|
|
111
|
+
const data = event.data as WorkerReceivePayload;
|
|
112
|
+
if (data.op === WorkerReceivePayloadOp.SessionInfoResponse && data.d.nonce === nonce) {
|
|
113
|
+
worker.removeEventListener('message', listener);
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
resolve(data.d.session);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Timeout to prevent hanging
|
|
120
|
+
const timeout = setTimeout(() => {
|
|
121
|
+
worker.removeEventListener('message', listener);
|
|
122
|
+
resolve(null);
|
|
123
|
+
}, 5000);
|
|
124
|
+
|
|
125
|
+
worker.addEventListener('message', listener);
|
|
126
|
+
worker.postMessage(payload);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public async fetchShardIdentity(shardId: number): Promise<number | null> {
|
|
131
|
+
const worker = this.workers.get(shardId);
|
|
132
|
+
if (!worker) return null;
|
|
133
|
+
|
|
134
|
+
const nonce = Math.random();
|
|
135
|
+
const payload: WorkerSendPayload = {
|
|
136
|
+
op: WorkerSendPayloadOp.FetchShardIdentity,
|
|
137
|
+
d: { shardId, nonce },
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
const listener = (event: MessageEvent) => {
|
|
142
|
+
const data = event.data as WorkerReceivePayload;
|
|
143
|
+
if (data.op === WorkerReceivePayloadOp.ShardIdentityResponse && data.d.nonce === nonce) {
|
|
144
|
+
worker.removeEventListener('message', listener);
|
|
145
|
+
resolve(data.d.shardId);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
worker.addEventListener('message', listener);
|
|
149
|
+
worker.postMessage(payload);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public async fetchStatus(): Promise<Collection<number, WebSocketShardStatus>> {
|
|
154
|
+
const statuses = new Collection<number, WebSocketShardStatus>();
|
|
155
|
+
const promises: Promise<void>[] = [];
|
|
156
|
+
|
|
157
|
+
for (const [shardId, worker] of this.workers) {
|
|
158
|
+
const nonce = Math.random();
|
|
159
|
+
const payload: WorkerSendPayload = {
|
|
160
|
+
op: WorkerSendPayloadOp.FetchStatus,
|
|
161
|
+
d: { shardId, nonce },
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const promise = new Promise<void>((resolve) => {
|
|
165
|
+
const listener = (event: MessageEvent) => {
|
|
166
|
+
const data = event.data as WorkerReceivePayload;
|
|
167
|
+
if (data.op === WorkerReceivePayloadOp.StatusResponse && data.d.nonce === nonce) {
|
|
168
|
+
worker.removeEventListener('message', listener);
|
|
169
|
+
statuses.set(shardId, data.d.status);
|
|
170
|
+
resolve();
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Timeout to prevent hanging
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
worker.removeEventListener('message', listener);
|
|
177
|
+
resolve();
|
|
178
|
+
}, 5000);
|
|
179
|
+
|
|
180
|
+
worker.addEventListener('message', listener);
|
|
181
|
+
worker.postMessage(payload);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
promises.push(promise);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await Promise.all(promises);
|
|
188
|
+
return statuses;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private onMessage(_shardId: number, payload: WorkerReceivePayload) {
|
|
192
|
+
switch (payload.op) {
|
|
193
|
+
case WorkerReceivePayloadOp.Connect:
|
|
194
|
+
// handled by spawn
|
|
195
|
+
break;
|
|
196
|
+
case WorkerReceivePayloadOp.Destroy:
|
|
197
|
+
// handled by destroy
|
|
198
|
+
break;
|
|
199
|
+
case WorkerReceivePayloadOp.Send:
|
|
200
|
+
// handled by send
|
|
201
|
+
break;
|
|
202
|
+
case WorkerReceivePayloadOp.SessionInfoResponse:
|
|
203
|
+
// Handled by fetchSessionInfo promise
|
|
204
|
+
break;
|
|
205
|
+
case WorkerReceivePayloadOp.ShardIdentityResponse:
|
|
206
|
+
// Handled by fetchShardIdentity promise
|
|
207
|
+
break;
|
|
208
|
+
case WorkerReceivePayloadOp.Event:
|
|
209
|
+
this.manager.emit(payload.d.event, ...payload.d.args || []);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async waitForReady(_shardId: number) {
|
|
215
|
+
// Implementation depends on how we signal ready.
|
|
216
|
+
// Assuming we receive a 'Ready' event forwarded from the worker.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export enum WorkerSendPayloadOp {
|
|
221
|
+
Connect,
|
|
222
|
+
Destroy,
|
|
223
|
+
Send,
|
|
224
|
+
FetchSessionInfo,
|
|
225
|
+
FetchShardIdentity,
|
|
226
|
+
FetchStatus,
|
|
227
|
+
SessionInfoResponse,
|
|
228
|
+
ShardIdentifyResponse,
|
|
229
|
+
StatusResponse,
|
|
230
|
+
Event,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export enum WorkerReceivePayloadOp {
|
|
234
|
+
Connect,
|
|
235
|
+
Destroy,
|
|
236
|
+
Send,
|
|
237
|
+
RetrieveSessionInfo,
|
|
238
|
+
UpdateSessionInfo,
|
|
239
|
+
WaitForIdentify,
|
|
240
|
+
CancelIdentify,
|
|
241
|
+
SessionInfoResponse,
|
|
242
|
+
ShardIdentityResponse,
|
|
243
|
+
StatusResponse,
|
|
244
|
+
Event,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface WorkerSendPayload {
|
|
248
|
+
op: WorkerSendPayloadOp;
|
|
249
|
+
d: any;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface WorkerReceivePayload {
|
|
253
|
+
op: WorkerReceivePayloadOp;
|
|
254
|
+
d: any;
|
|
255
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IdentifyThrottlers are responsible for dictating when a shard is allowed to identify.
|
|
3
|
+
*
|
|
4
|
+
* @see {@link https://discord.com/developers/docs/topics/gateway#sharding-max-concurrency}
|
|
5
|
+
*/
|
|
6
|
+
export interface IIdentifyThrottler {
|
|
7
|
+
/**
|
|
8
|
+
* Resolves once the given shard should be allowed to identify, or rejects if the operation was aborted.
|
|
9
|
+
*/
|
|
10
|
+
waitForIdentify(shardId: number, signal: AbortSignal): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Collection } from '@ovencord/collection';
|
|
2
|
+
import { AsyncQueue } from '@ovencord/util';
|
|
3
|
+
|
|
4
|
+
const sleep = Bun.sleep;
|
|
5
|
+
import type { IIdentifyThrottler } from './IIdentifyThrottler.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The state of a rate limit key's identify queue.
|
|
9
|
+
*/
|
|
10
|
+
export interface IdentifyState {
|
|
11
|
+
queue: AsyncQueue;
|
|
12
|
+
resetsAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Local, in-memory identify throttler.
|
|
17
|
+
*/
|
|
18
|
+
export class SimpleIdentifyThrottler implements IIdentifyThrottler {
|
|
19
|
+
private readonly states = new Collection<number, IdentifyState>();
|
|
20
|
+
|
|
21
|
+
public constructor(private readonly maxConcurrency: number) {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* {@inheritDoc IIdentifyThrottler.waitForIdentify}
|
|
25
|
+
*/
|
|
26
|
+
public async waitForIdentify(shardId: number, signal: AbortSignal): Promise<void> {
|
|
27
|
+
const key = shardId % this.maxConcurrency;
|
|
28
|
+
|
|
29
|
+
const state = this.states.ensure(key, () => ({
|
|
30
|
+
queue: new AsyncQueue(),
|
|
31
|
+
resetsAt: Number.POSITIVE_INFINITY,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
await state.queue.wait({ signal });
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const diff = state.resetsAt - Date.now();
|
|
38
|
+
if (diff > 0 && diff <= 5_000) {
|
|
39
|
+
// To account for the latency the IDENTIFY payload goes through, we add a bit more wait time
|
|
40
|
+
const time = diff + Math.random() * 1_500;
|
|
41
|
+
await sleep(time);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
state.resetsAt = Date.now() + 5_000;
|
|
45
|
+
} finally {
|
|
46
|
+
state.queue.shift();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun Native Compression Handler for Discord Gateway
|
|
3
|
+
*
|
|
4
|
+
* This module replaces node:zlib and zlib-sync with Bun's native compression APIs.
|
|
5
|
+
* Discord uses zlib-stream compression with the suffix 0x00 0x00 0xFF 0xFF to indicate
|
|
6
|
+
* a complete message.
|
|
7
|
+
*
|
|
8
|
+
* Performance: ~30-50% faster than Node.js zlib due to Zig implementation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Buffer accumulator for Discord's streaming compression.
|
|
13
|
+
* Discord sends chunked zlib data; we need to accumulate chunks until we see the suffix.
|
|
14
|
+
*/
|
|
15
|
+
class BunCompressionBuffer {
|
|
16
|
+
private chunks: Uint8Array[] = [];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Add a chunk to the buffer
|
|
20
|
+
*/
|
|
21
|
+
push(chunk: Uint8Array): void {
|
|
22
|
+
this.chunks.push(chunk);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if the last chunk has the zlib suffix (0x00 0x00 0xFF 0xFF)
|
|
27
|
+
* This indicates a complete Discord gateway message.
|
|
28
|
+
*/
|
|
29
|
+
hasSuffix(chunk: Uint8Array): boolean {
|
|
30
|
+
return (
|
|
31
|
+
chunk.length >= 4 &&
|
|
32
|
+
chunk[chunk.length - 4] === 0x00 &&
|
|
33
|
+
chunk[chunk.length - 3] === 0x00 &&
|
|
34
|
+
chunk[chunk.length - 2] === 0xff &&
|
|
35
|
+
chunk[chunk.length - 1] === 0xff
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Concatenate all accumulated chunks into a single buffer
|
|
41
|
+
*/
|
|
42
|
+
concat(): Uint8Array {
|
|
43
|
+
// Calculate total length
|
|
44
|
+
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
45
|
+
|
|
46
|
+
// Create output buffer
|
|
47
|
+
const result = new Uint8Array(totalLength);
|
|
48
|
+
|
|
49
|
+
// Copy all chunks
|
|
50
|
+
let offset = 0;
|
|
51
|
+
for (const chunk of this.chunks) {
|
|
52
|
+
result.set(chunk, offset);
|
|
53
|
+
offset += chunk.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear the buffer after processing
|
|
61
|
+
*/
|
|
62
|
+
clear(): void {
|
|
63
|
+
this.chunks = [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get number of chunks currently in buffer
|
|
68
|
+
*/
|
|
69
|
+
get length(): number {
|
|
70
|
+
return this.chunks.length;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bun native inflate for Discord compression
|
|
76
|
+
* Handles both identify compression (one-shot) and transport compression (streaming)
|
|
77
|
+
*/
|
|
78
|
+
export class BunInflateHandler {
|
|
79
|
+
private buffer: BunCompressionBuffer = new BunCompressionBuffer();
|
|
80
|
+
private textDecoder = new TextDecoder();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Process a compressed chunk from Discord.
|
|
84
|
+
* For identify compression: inflates immediately
|
|
85
|
+
* For transport compression: accumulates until suffix, then inflates
|
|
86
|
+
*
|
|
87
|
+
* @param data - Compressed data chunk
|
|
88
|
+
* @param isIdentify - Whether this is identify compression (one-shot)
|
|
89
|
+
* @returns Decompressed string or null if incomplete (streaming)
|
|
90
|
+
*/
|
|
91
|
+
process(data: Uint8Array, isIdentify: boolean = false): string | null {
|
|
92
|
+
// Identify compression is always a complete message
|
|
93
|
+
if (isIdentify) {
|
|
94
|
+
try {
|
|
95
|
+
const decompressed = Bun.inflateSync(data as any);
|
|
96
|
+
return this.textDecoder.decode(decompressed);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Bun inflate failed for identify compression: ${error instanceof Error ? error.message : String(error)}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Transport compression - check for suffix to know when message is complete
|
|
105
|
+
this.buffer.push(data);
|
|
106
|
+
|
|
107
|
+
if (!this.buffer.hasSuffix(data)) {
|
|
108
|
+
// Message not complete yet, wait for more chunks
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// We have a complete message! Concatenate and decompress
|
|
113
|
+
const combined = this.buffer.concat();
|
|
114
|
+
this.buffer.clear();
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const decompressed = Bun.inflateSync(combined as any);
|
|
118
|
+
return this.textDecoder.decode(decompressed);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
this.buffer.clear(); // Clear buffer on error to prevent corruption
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Bun inflate failed for transport compression: ${error instanceof Error ? error.message : String(error)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reset the buffer (used when reconnecting)
|
|
129
|
+
*/
|
|
130
|
+
reset(): void {
|
|
131
|
+
this.buffer.clear();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Bun native gzip for identify payload compression
|
|
137
|
+
*/
|
|
138
|
+
export function compressIdentifyPayload(payload: string): Uint8Array {
|
|
139
|
+
try {
|
|
140
|
+
return Bun.gzipSync(payload);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error(`Bun gzip failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|