wispjs 2.4.0 → 3.0.0

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.
@@ -1,4 +1,4 @@
1
- import { io } from "socket.io-client";
1
+ import { WispWebSocket } from "./ws_adapter.js";
2
2
  const WISP_DEBUG = process.env.WISP_DEBUG === "true";
3
3
  /**
4
4
  * A single Worker within a {@link WebsocketPool}
@@ -9,17 +9,16 @@ const WISP_DEBUG = process.env.WISP_DEBUG === "true";
9
9
  */
10
10
  class PoolWorker {
11
11
  constructor(pool) {
12
+ this.intentionalDisconnect = false;
13
+ this.reconnectAttempts = 0;
14
+ this.maxReconnectAttempts = 3;
12
15
  this.pool = pool;
13
- this.ready = false;
16
+ this.connected = false;
17
+ this.busy = false;
14
18
  this.done = false;
15
19
  this.idx = pool.workers.length;
16
20
  this.token = pool.token;
17
- this.socket = io(pool.url, {
18
- forceNew: true,
19
- transports: ["websocket"],
20
- addTrailingSlash: true,
21
- autoConnect: false
22
- });
21
+ this.url = pool.url;
23
22
  const logPrefix = `[Worker #${this.idx}]`;
24
23
  this.logger = {
25
24
  log: (...args) => console.log(logPrefix, args),
@@ -30,7 +29,22 @@ class PoolWorker {
30
29
  console.debug(logPrefix, args);
31
30
  }
32
31
  };
33
- this.connect().then(() => this.processWork()).catch(err => this.logger.error(err));
32
+ this.start();
33
+ this.processWork();
34
+ }
35
+ createSocket() {
36
+ this.socket = new WispWebSocket(this.url, {
37
+ origin: this.pool.origin
38
+ });
39
+ }
40
+ start() {
41
+ this.createSocket();
42
+ this.connect()
43
+ .then(() => this.logger.log("Connection established"))
44
+ .catch((err) => {
45
+ this.logger.error("Connection failed", err);
46
+ this.handleDisconnect("connection failure");
47
+ });
34
48
  }
35
49
  connect() {
36
50
  const socket = this.socket;
@@ -40,11 +54,10 @@ class PoolWorker {
40
54
  return new Promise((resolve, reject) => {
41
55
  const timeout = setTimeout(() => {
42
56
  logger.error("Socket didn't connect in time");
43
- reject("Connection Timeout");
57
+ reject(new Error("Connection Timeout"));
44
58
  }, 10000);
45
59
  socket.on("connect", () => {
46
- logger.log("Connected to WebSocket");
47
- logger.log("Emitting:", "auth", this.token);
60
+ logger.log("Connected to WebSocket, authenticating");
48
61
  socket.emit("auth", this.token);
49
62
  });
50
63
  socket.on("error", (reason) => {
@@ -52,28 +65,65 @@ class PoolWorker {
52
65
  });
53
66
  socket.on("connect_error", (error) => {
54
67
  logger.error(`WebSocket Connect error: ${error.toString()}`);
55
- this.done = true;
56
68
  clearTimeout(timeout);
57
- reject(`Connection error: ${error.toString()}`);
69
+ reject(error);
58
70
  });
59
71
  socket.on("disconnect", (reason) => {
60
72
  logger.log(`Disconnected from WebSocket: ${reason}`);
73
+ this.connected = false;
74
+ this.handleDisconnect(reason);
61
75
  });
62
- socket.on("auth_success", () => {
76
+ socket.on("auth success", () => {
63
77
  logger.log("Auth success");
64
- this.ready = true;
78
+ this.reconnectAttempts = 0;
79
+ this.connected = true;
65
80
  clearTimeout(timeout);
66
81
  resolve();
67
82
  });
68
83
  socket.connect();
69
84
  });
70
85
  }
86
+ // Recovers from an unexpected disconnect by reconnecting with a freshly
87
+ // fetched token (the JWT is short-lived). After maxReconnectAttempts the
88
+ // worker is marked dead so the pool stops handing it work.
89
+ async handleDisconnect(reason) {
90
+ this.connected = false;
91
+ if (this.intentionalDisconnect || this.done) {
92
+ return;
93
+ }
94
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
95
+ this.logger.error(`Giving up after ${this.reconnectAttempts} reconnect attempts (${reason})`);
96
+ this.done = true;
97
+ this.pool.onWorkerDone();
98
+ return;
99
+ }
100
+ this.reconnectAttempts++;
101
+ const backoff = Math.min(1000 * this.reconnectAttempts, 5000);
102
+ this.logger.log(`Reconnecting (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}) in ${backoff}ms`);
103
+ await new Promise((resolve) => setTimeout(resolve, backoff));
104
+ if (this.intentionalDisconnect || this.done) {
105
+ return;
106
+ }
107
+ if (this.pool.refreshDetails) {
108
+ try {
109
+ const details = await this.pool.refreshDetails();
110
+ this.url = details.url;
111
+ this.token = details.token;
112
+ }
113
+ catch (e) {
114
+ this.logger.error("Failed to refresh websocket details for reconnect", e);
115
+ }
116
+ }
117
+ this.start();
118
+ }
71
119
  disconnect() {
72
- this.ready = false;
73
- return new Promise((resolve, reject) => {
120
+ this.intentionalDisconnect = true;
121
+ this.connected = false;
122
+ return new Promise((resolve) => {
74
123
  const timeout = setTimeout(() => {
75
124
  this.logger.error("Socket didn't disconnect in time");
76
- reject();
125
+ this.done = true;
126
+ resolve();
77
127
  }, 5000);
78
128
  this.socket.once("disconnect", () => {
79
129
  this.done = true;
@@ -85,13 +135,13 @@ class PoolWorker {
85
135
  }
86
136
  async processWork() {
87
137
  while (!this.done) {
88
- if (!this.ready) {
138
+ if (!this.connected || this.busy) {
89
139
  await new Promise(resolve => setTimeout(resolve, 100));
90
140
  continue;
91
141
  }
92
142
  const work = this.pool.getWork();
93
143
  if (work) {
94
- this.ready = false;
144
+ this.busy = true;
95
145
  try {
96
146
  this.logger.debug("Running my work");
97
147
  await work(this);
@@ -102,7 +152,7 @@ class PoolWorker {
102
152
  this.logger.error(e);
103
153
  }
104
154
  finally {
105
- this.ready = true;
155
+ this.busy = false;
106
156
  }
107
157
  }
108
158
  else {
@@ -123,11 +173,15 @@ class PoolWorker {
123
173
  * @internal
124
174
  */
125
175
  export class WebsocketPool {
126
- constructor(url, token) {
176
+ constructor(url, token, origin, refreshDetails) {
127
177
  const envMaxWorkers = process.env.WISP_MAX_WORKERS;
128
178
  this.maxWorkers = envMaxWorkers ? parseInt(envMaxWorkers) : 5;
179
+ const envAcquire = process.env.WISP_ACQUIRE_TIMEOUT;
180
+ this.acquireTimeout = envAcquire ? parseInt(envAcquire) : 60000;
129
181
  this.token = token;
130
182
  this.url = url;
183
+ this.origin = origin;
184
+ this.refreshDetails = refreshDetails;
131
185
  const logPrefix = "[Pool]";
132
186
  this.logger = {
133
187
  log: (...args) => console.log(logPrefix, args),
@@ -146,7 +200,22 @@ export class WebsocketPool {
146
200
  }
147
201
  }
148
202
  getWork() {
149
- return this.queue.shift();
203
+ return this.queue.shift()?.task;
204
+ }
205
+ allWorkersDone() {
206
+ return this.workers.length > 0 && this.workers.every((worker) => worker.done);
207
+ }
208
+ // Called by a worker when it gives up permanently. If no workers remain,
209
+ // queued work can never run, so reject it instead of letting it hang.
210
+ onWorkerDone() {
211
+ if (!this.allWorkersDone()) {
212
+ return;
213
+ }
214
+ this.logger.error("All workers are dead - draining queued work");
215
+ const error = new Error("WebsocketPool has no live workers");
216
+ while (this.queue.length > 0) {
217
+ this.queue.shift()?.reject(error);
218
+ }
150
219
  }
151
220
  async disconnect() {
152
221
  this.logger.log("Disconnecting all workers...");
@@ -154,17 +223,47 @@ export class WebsocketPool {
154
223
  this.logger.log("All workers disconnected");
155
224
  }
156
225
  async run(work) {
157
- return new Promise(async (resolve, reject) => {
158
- this.queue.push(async (worker) => {
159
- try {
160
- const result = await work(worker);
161
- resolve(result);
226
+ return new Promise((resolve, reject) => {
227
+ if (this.allWorkersDone()) {
228
+ reject(new Error("WebsocketPool has no live workers"));
229
+ return;
230
+ }
231
+ let settled = false;
232
+ const item = {
233
+ task: async (worker) => {
234
+ if (settled) {
235
+ return;
236
+ }
237
+ settled = true;
238
+ clearTimeout(acquireTimer);
239
+ try {
240
+ const result = await work(worker);
241
+ resolve(result);
242
+ }
243
+ catch (e) {
244
+ worker.logger.error("Failed to run a job!");
245
+ reject(e);
246
+ }
247
+ },
248
+ reject: (reason) => {
249
+ if (settled) {
250
+ return;
251
+ }
252
+ settled = true;
253
+ clearTimeout(acquireTimer);
254
+ reject(reason);
162
255
  }
163
- catch (e) {
164
- worker.logger.error("Failed to run a job!");
165
- reject(e);
256
+ };
257
+ // Backstop so work can't sit in the queue forever if every worker
258
+ // is wedged. Worker death is handled separately by onWorkerDone.
259
+ const acquireTimer = setTimeout(() => {
260
+ const idx = this.queue.indexOf(item);
261
+ if (idx !== -1) {
262
+ this.queue.splice(idx, 1);
166
263
  }
167
- });
264
+ item.reject(new Error(`Timed out waiting for an available worker after ${this.acquireTimeout}ms`));
265
+ }, this.acquireTimeout);
266
+ this.queue.push(item);
168
267
  });
169
268
  }
170
269
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Options for the {@link WispWebSocket} adapter
3
+ *
4
+ * @param origin The value to send as the `Origin` header during the upgrade
5
+ *
6
+ * @internal
7
+ */
8
+ export interface WsAdapterOptions {
9
+ origin?: string;
10
+ }
11
+ /**
12
+ * A thin adapter that exposes a Socket.IO-like event API over a plain `ws`
13
+ * WebSocket.
14
+ *
15
+ * The new backend speaks a simple JSON envelope rather than the Socket.IO /
16
+ * Engine.IO framing:
17
+ *
18
+ * ```json
19
+ * { "event": "console output", "args": ["...line..."] }
20
+ * ```
21
+ *
22
+ * `args` is omitted entirely when an event carries no payload
23
+ * (e.g. `{ "event": "auth success" }`).
24
+ *
25
+ * This class translates between that envelope and the `.on/.once/.off/.emit`
26
+ * surface the rest of the codebase already uses, so the calling code barely
27
+ * changes.
28
+ *
29
+ * @remarks
30
+ * We *compose* an EventEmitter instead of extending one on purpose: extending
31
+ * it would mean overriding `emit()`, which EventEmitter also calls internally
32
+ * (e.g. the `newListener` event) — so every `.on()` would try to send a frame
33
+ * to the server. Composition keeps "emit to the wire" and "fire local
34
+ * listeners" cleanly separate.
35
+ *
36
+ * @internal
37
+ */
38
+ export declare class WispWebSocket {
39
+ private url;
40
+ private origin?;
41
+ private ws?;
42
+ private emitter;
43
+ private outgoingListener?;
44
+ constructor(url: string, opts?: WsAdapterOptions);
45
+ /**
46
+ * Opens the underlying WebSocket and wires up frame translation.
47
+ */
48
+ connect(): this;
49
+ /**
50
+ * Sends an event to the server as a `{ event, args }` JSON frame.
51
+ *
52
+ * @remarks
53
+ * Unlike EventEmitter.emit, this does NOT fire local listeners — it writes
54
+ * to the socket. Incoming frames fire local listeners via the internal
55
+ * emitter.
56
+ */
57
+ emit(event: string, ...args: any[]): boolean;
58
+ on(event: string, listener: (...args: any[]) => void): this;
59
+ once(event: string, listener: (...args: any[]) => void): this;
60
+ /**
61
+ * Removes a specific listener, or — matching Socket.IO's behaviour — all
62
+ * listeners for an event when no listener is given.
63
+ */
64
+ off(event: string, listener?: (...args: any[]) => void): this;
65
+ removeAllListeners(event?: string): this;
66
+ /**
67
+ * Registers a callback invoked for every outgoing emit (parity with
68
+ * Socket.IO's `onAnyOutgoing`, used for logging).
69
+ */
70
+ onAnyOutgoing(listener: (...args: any[]) => void): this;
71
+ disconnect(): this;
72
+ }
@@ -0,0 +1,130 @@
1
+ import WebSocket from "ws";
2
+ import { EventEmitter } from "node:events";
3
+ /**
4
+ * A thin adapter that exposes a Socket.IO-like event API over a plain `ws`
5
+ * WebSocket.
6
+ *
7
+ * The new backend speaks a simple JSON envelope rather than the Socket.IO /
8
+ * Engine.IO framing:
9
+ *
10
+ * ```json
11
+ * { "event": "console output", "args": ["...line..."] }
12
+ * ```
13
+ *
14
+ * `args` is omitted entirely when an event carries no payload
15
+ * (e.g. `{ "event": "auth success" }`).
16
+ *
17
+ * This class translates between that envelope and the `.on/.once/.off/.emit`
18
+ * surface the rest of the codebase already uses, so the calling code barely
19
+ * changes.
20
+ *
21
+ * @remarks
22
+ * We *compose* an EventEmitter instead of extending one on purpose: extending
23
+ * it would mean overriding `emit()`, which EventEmitter also calls internally
24
+ * (e.g. the `newListener` event) — so every `.on()` would try to send a frame
25
+ * to the server. Composition keeps "emit to the wire" and "fire local
26
+ * listeners" cleanly separate.
27
+ *
28
+ * @internal
29
+ */
30
+ export class WispWebSocket {
31
+ constructor(url, opts = {}) {
32
+ this.emitter = new EventEmitter();
33
+ this.url = url;
34
+ this.origin = opts.origin;
35
+ // The pool registers a handful of listeners per worker; silence the
36
+ // default 10-listener warning.
37
+ this.emitter.setMaxListeners(0);
38
+ }
39
+ /**
40
+ * Opens the underlying WebSocket and wires up frame translation.
41
+ */
42
+ connect() {
43
+ const ws = new WebSocket(this.url, this.origin ? { origin: this.origin } : undefined);
44
+ this.ws = ws;
45
+ // Map ws lifecycle onto the Socket.IO-style events the pool listens for
46
+ ws.on("open", () => this.emitter.emit("connect"));
47
+ ws.on("close", (code, reason) => {
48
+ this.emitter.emit("disconnect", reason?.toString() || `code ${code}`);
49
+ });
50
+ ws.on("error", (err) => this.emitter.emit("connect_error", err));
51
+ ws.on("message", (data, isBinary) => {
52
+ if (isBinary)
53
+ return;
54
+ let frame;
55
+ try {
56
+ frame = JSON.parse(data.toString());
57
+ }
58
+ catch {
59
+ return; // ignore anything that isn't our JSON envelope
60
+ }
61
+ const event = frame.event;
62
+ if (!event)
63
+ return;
64
+ const args = frame.args ?? [];
65
+ // EventEmitter throws if "error" is emitted with no listener; guard it.
66
+ if (event === "error" && this.emitter.listenerCount("error") === 0) {
67
+ console.error("[ws] server error frame:", ...args);
68
+ return;
69
+ }
70
+ this.emitter.emit(event, ...args);
71
+ });
72
+ return this;
73
+ }
74
+ /**
75
+ * Sends an event to the server as a `{ event, args }` JSON frame.
76
+ *
77
+ * @remarks
78
+ * Unlike EventEmitter.emit, this does NOT fire local listeners — it writes
79
+ * to the socket. Incoming frames fire local listeners via the internal
80
+ * emitter.
81
+ */
82
+ emit(event, ...args) {
83
+ this.outgoingListener?.(event, ...args);
84
+ const frame = JSON.stringify(args.length ? { event, args } : { event });
85
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
86
+ this.ws.send(frame);
87
+ }
88
+ else {
89
+ console.error(`[ws] dropped outgoing "${event}" — socket not open`);
90
+ }
91
+ return true;
92
+ }
93
+ on(event, listener) {
94
+ this.emitter.on(event, listener);
95
+ return this;
96
+ }
97
+ once(event, listener) {
98
+ this.emitter.once(event, listener);
99
+ return this;
100
+ }
101
+ /**
102
+ * Removes a specific listener, or — matching Socket.IO's behaviour — all
103
+ * listeners for an event when no listener is given.
104
+ */
105
+ off(event, listener) {
106
+ if (listener) {
107
+ this.emitter.off(event, listener);
108
+ }
109
+ else {
110
+ this.emitter.removeAllListeners(event);
111
+ }
112
+ return this;
113
+ }
114
+ removeAllListeners(event) {
115
+ this.emitter.removeAllListeners(event);
116
+ return this;
117
+ }
118
+ /**
119
+ * Registers a callback invoked for every outgoing emit (parity with
120
+ * Socket.IO's `onAnyOutgoing`, used for logging).
121
+ */
122
+ onAnyOutgoing(listener) {
123
+ this.outgoingListener = listener;
124
+ return this;
125
+ }
126
+ disconnect() {
127
+ this.ws?.close();
128
+ return this;
129
+ }
130
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "wispjs",
3
- "version": "2.4.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "description": "A package for interacting with Wisp-based server panels",
6
6
  "main": "dist/wisp.js",
7
7
  "types": "dist/wisp.d.ts",
8
8
  "scripts": {
9
9
  "build": "tsc",
10
- "build-docs": "typedoc wisp.ts wisp_api/index.ts wisp_socket/index.ts wisp_api/apis/*"
10
+ "build-docs": "typedoc wisp.ts wisp_api/index.ts wisp_socket/index.ts wisp_socket/git.ts wisp_socket/filesystem.ts wisp_api/apis/*"
11
11
  },
12
12
  "repository": {
13
13
  "type": "git",
@@ -20,11 +20,12 @@
20
20
  },
21
21
  "homepage": "https://github.com/CFC-Servers/WispJS#readme",
22
22
  "dependencies": {
23
- "socket.io-client": "^4.7.2",
24
- "strip-ansi": "^7.1.0"
23
+ "strip-ansi": "^7.1.0",
24
+ "ws": "^8.21.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^20.5.4",
28
+ "@types/ws": "^8.18.1",
28
29
  "typedoc": "^0.25.4",
29
30
  "typescript": "^5.3.3"
30
31
  }
@@ -1,77 +0,0 @@
1
- name: Deploy
2
-
3
- on:
4
- workflow_dispatch:
5
- inputs:
6
- version_tag:
7
- description: "Version tag for the package"
8
- required: true
9
-
10
- jobs:
11
- build:
12
- runs-on: ubuntu-latest
13
- steps:
14
- - uses: actions/checkout@v4
15
-
16
- - uses: actions/setup-node@v4
17
- with:
18
- node-version: 18
19
-
20
- - name: Update package version
21
- run: |
22
- version=${{ inputs.version_tag }}
23
- npm version $version --no-git-tag-version
24
-
25
- - name: Install packages
26
- run: |
27
- npm ci
28
-
29
- - name: Build
30
- run: |
31
- npm run build
32
-
33
- - name: Generate Docs
34
- run: |
35
- npm run build-docs
36
- mv docs ../docs_wip
37
-
38
- - name: Remove Non-distributables
39
- run: |
40
- rm .gitignore
41
- rm tsconfig.json
42
- rm wisp.ts
43
- rm -rf wisp_api wisp_socket .github
44
-
45
- - name: Publish
46
- uses: JS-DevTools/npm-publish@v3
47
- with:
48
- token: ${{ secrets.NPM_PUBLISH_TOKEN }}
49
-
50
- - name: Configure Git User
51
- run: |
52
- git config user.name github-actions
53
- git config user.email github-actions@github.com
54
-
55
- - name: Push package version change
56
- run: |
57
- git add package.json
58
- git add package-lock.json
59
- git commit -m "Update package.json version to: ${{ inputs.version_tag }}" && \
60
- git push --force-with-lease origin main || \
61
- echo "Version tag unchanaged"
62
-
63
- - name: Push new tag
64
- run: |
65
- git tag "${{ inputs.version_tag }}"
66
- git push --tags
67
-
68
- - name: Publish Docs
69
- run: |
70
- # Delete everything except .git
71
- find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
72
-
73
- # Move the docs back and commit only them
74
- mv ../docs_wip docs
75
- git add .
76
- git commit -m "Update Docs for version: ${{ inputs.version_tag }}"
77
- git push --force origin HEAD:docs
package/tsconfig.json DELETED
@@ -1,19 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "module": "ESNext",
5
-
6
- "outDir": "./dist/",
7
- "declaration": true,
8
-
9
- "esModuleInterop": true,
10
- "forceConsistentCasingInFileNames": true,
11
-
12
- "strict": true,
13
- "skipLibCheck": true,
14
- "moduleResolution": "node"
15
- },
16
- "include": ["*.ts"],
17
- "exclude": ["node_modules"]
18
- }
19
-
package/wisp.ts DELETED
@@ -1,43 +0,0 @@
1
- import { WispAPI } from "./wisp_api/index.js";
2
- import { WispSocket } from "./wisp_socket/index.js";
3
-
4
- export interface WispInterface {
5
- socket: WispSocket;
6
- api: WispAPI;
7
- logger: any;
8
- }
9
-
10
- /**
11
- * The primary Wisp Interface, exposing interactions with both the HTTP and Websockets API
12
- *
13
- * @param domain The Domain of the Pterodactyl/Wisp panel (e.g. `my.gamepanel.gg`)
14
- * @param uuid The UUID of the server to reference in all API requests
15
- * @param token The panel API token to use for authorization
16
- * @param ghPAT The Github Personal Access Token used for Cloning/Pulling of private repositories. This may be omitted if you do not need to interact with private repositories
17
- *
18
- * @public
19
- */
20
- export class WispInterface {
21
- constructor(domain: string, uuid: string, token: string, ghPAT?: string) {
22
- this.logger = {
23
- info: (msg: any) => {
24
- console.log(msg);
25
- },
26
- error: (msg: string) => {
27
- console.error(msg);
28
- }
29
- };
30
-
31
- this.api = new WispAPI(domain, uuid, token, this.logger);
32
- this.socket = new WispSocket(this.logger, this.api, ghPAT);
33
- }
34
-
35
- /**
36
- * Manually disconnects from the Websocket connection(s)
37
- *
38
- * @public
39
- */
40
- async disconnect() {
41
- await this.socket.disconnect();
42
- }
43
- }