wispjs 2.4.0 → 3.0.1

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,9 +1,19 @@
1
- import { Socket } from "socket.io-client";
2
- type Logger = {
1
+ import { WispWebSocket } from "./ws_adapter.js";
2
+ export type Logger = {
3
3
  log(...args: any[]): void;
4
4
  error(...args: any[]): void;
5
5
  debug(...args: any[]): void;
6
6
  };
7
+ /**
8
+ * The minimal worker surface handed to a job run via {@link WispSocket.runWorker}:
9
+ * the connected socket and a prefixed logger.
10
+ *
11
+ * @internal
12
+ */
13
+ export interface SocketWorker {
14
+ socket: WispWebSocket;
15
+ logger: Logger;
16
+ }
7
17
  /**
8
18
  * The struct used to define the events that can be sent from the server to the client
9
19
  *
@@ -11,13 +21,14 @@ type Logger = {
11
21
  */
12
22
  export interface ServerToClientEvents {
13
23
  "error": (message: string) => void;
14
- "auth_success": (message: string) => void;
15
- "filesearch-results": (data: FilesearchResults) => void;
16
- "git-error": (data: string) => void;
17
- "git-success": (message?: string) => void;
18
- "git-clone": (data: GitCloneData) => void;
19
- "git-pull": (data: GitPullData) => void;
20
- "console": (message: ConsoleMessage) => void;
24
+ "auth success": () => void;
25
+ "console output": (line: string) => void;
26
+ "status": (state: string) => void;
27
+ "stats": (statsJson: string) => void;
28
+ "filesearch results": (json: string) => void;
29
+ "fs:result": (json: string) => void;
30
+ "fs:error": (json: string) => void;
31
+ "fs:progress": (json: string) => void;
21
32
  "initial status": (message: any) => void;
22
33
  }
23
34
  /**
@@ -27,72 +38,150 @@ export interface ServerToClientEvents {
27
38
  */
28
39
  export interface ClientToServerEvents {
29
40
  "auth": (token: string) => void;
30
- "filesearch-start": (query: string) => void;
31
- "git-clone": (data: GitCloneData) => void;
32
- "git-pull": (data: GitPullData) => void;
41
+ "filesearch start": (json: string) => void;
42
+ "fs:request": (json: string) => void;
33
43
  "send command": (command: string) => void;
34
44
  }
35
45
  /**
36
- * The struct sent from the server containing console messages
46
+ * Return struct after finishing a Git Clone action
37
47
  *
38
- * @param type The type of message. Currently unknown what varients exist
39
- * @param line The actual content of the console messages
48
+ * @param folder_name The name of the folder the repo was cloned into
49
+ * @param commit The HEAD commit hash after the clone
50
+ * @param branch The branch that was cloned
40
51
  *
41
52
  * @internal
42
53
  */
43
- export interface ConsoleMessage {
44
- type: string;
45
- line: string;
54
+ export interface GitCloneResult {
55
+ folder_name: string;
56
+ commit: string;
57
+ branch: string;
46
58
  }
47
59
  /**
48
- * Struct used to initiate a Git Clone action
60
+ * Struct returned after a Git Pull action finishes.
49
61
  *
50
- * @param dir The directory to clone into
51
- * @param url The HTTPS URL to clone
52
- * @param branch The repository branch
53
- * @param authkey The authentication key to use when pulling
62
+ * @param commit The HEAD commit hash after the pull
63
+ * @param branch The branch that was pulled
64
+ * @param files_changed How many files changed (0 when already up to date)
65
+ * @param up_to_date Whether the repo was already up to date
54
66
  *
55
67
  * @internal
56
68
  */
57
- export interface GitCloneData {
58
- dir: string;
59
- url: string;
69
+ export interface GitPullResult {
70
+ commit: string;
60
71
  branch: string;
61
- authkey?: string | undefined;
72
+ files_changed: number;
73
+ up_to_date: boolean;
62
74
  }
63
75
  /**
64
- * Return struct after finishing a Git Clone action
76
+ * Payload for an `fs:request`. Sent as a JSON string; `op`-specific fields go
77
+ * alongside `req` and `op`.
65
78
  *
66
- * @param isPrivate Whether or not the repository is private
79
+ * @internal
80
+ */
81
+ export interface FsRequest {
82
+ req: string;
83
+ op: string;
84
+ directory?: string;
85
+ [key: string]: any;
86
+ }
87
+ /**
88
+ * The `fs:result` success envelope; `data` carries the op-specific payload.
67
89
  *
68
90
  * @internal
69
91
  */
70
- export interface GitCloneResult {
71
- isPrivate: boolean;
92
+ export interface FsResult<T = any> {
93
+ req: string;
94
+ data?: T;
72
95
  }
73
96
  /**
74
- * Struct used to initiate a Git Pull action
97
+ * The `fs:error` envelope. Errors arrive as a separate event from `fs:result`,
98
+ * correlated by `req` (e.g. `code: "auth_required"` / `"auth_invalid"`).
75
99
  *
76
- * @param dir The directory to pull
77
- * @param authkey The authentication key to use when pulling
100
+ * @internal
101
+ */
102
+ export interface FsError {
103
+ req: string;
104
+ code: string;
105
+ message: string;
106
+ }
107
+ /**
108
+ * The parsed `fs:progress` envelope, correlated by `req`. Comes in two shapes:
109
+ * text lines (`kind: "stdout" | "stderr"`, `line`) and structured progress
110
+ * (`kind: "progress"` with `phase`/`current`/`total`/`percent`).
78
111
  *
79
112
  * @internal
80
113
  */
81
- export interface GitPullData {
82
- dir: string;
83
- authkey?: string;
114
+ export interface FsProgress {
115
+ req: string;
116
+ kind: "stdout" | "stderr" | "progress" | string;
117
+ line?: string;
118
+ phase?: string;
119
+ current?: number;
120
+ total?: number;
121
+ percent?: number;
84
122
  }
85
123
  /**
86
- * Struct returned after a Git Pull action finishes
124
+ * Result of a `git-status` op. Repo-specific fields are absent when
125
+ * `is_repo` is false.
87
126
  *
88
- * @param output The actual output
89
- * @param isPrivate Whether or not the repository is private
127
+ * @internal
128
+ */
129
+ export interface GitStatusResult {
130
+ is_repo: boolean;
131
+ ahead: number;
132
+ behind: number;
133
+ dirty: boolean;
134
+ repo_root?: string;
135
+ branch?: string;
136
+ commit?: string;
137
+ remote_url?: string;
138
+ }
139
+ /**
140
+ * A single entry in a `list` op result.
90
141
  *
91
142
  * @internal
92
143
  */
93
- export interface GitPullResult {
94
- output: string;
95
- isPrivate: boolean;
144
+ export interface FileEntry {
145
+ name: string;
146
+ created: string;
147
+ modified: string;
148
+ mode: string;
149
+ mode_bits: string;
150
+ size: number;
151
+ directory: boolean;
152
+ file: boolean;
153
+ symlink: boolean;
154
+ mime: string;
155
+ child_count?: number;
156
+ }
157
+ /**
158
+ * Options for a `list` op.
159
+ *
160
+ * @internal
161
+ */
162
+ export interface ListOptions {
163
+ search?: string;
164
+ page?: number;
165
+ perPage?: number;
166
+ sort?: string;
167
+ sortDir?: "asc" | "desc";
168
+ }
169
+ /**
170
+ * Result of a `list` op: a paginated directory listing.
171
+ *
172
+ * @internal
173
+ */
174
+ export interface ListResult {
175
+ data: FileEntry[];
176
+ meta: {
177
+ pagination: {
178
+ count: number;
179
+ currentPage: number;
180
+ perPage: number;
181
+ total: number;
182
+ totalPages: number;
183
+ };
184
+ };
96
185
  }
97
186
  /**
98
187
  * An individual filesearch result
@@ -123,10 +212,33 @@ export interface FilesearchResults {
123
212
  tooMany: boolean;
124
213
  }
125
214
  /**
126
- * The events that can be sent from the server to the client
215
+ * Result of an op that just reports success (e.g. `mkdir`).
216
+ *
217
+ * @internal
218
+ */
219
+ export interface FsOkResult {
220
+ ok: boolean;
221
+ }
222
+ /**
223
+ * Result of a `conflicts` op — the subset of the given paths that already
224
+ * exist / would conflict.
225
+ *
226
+ * @internal
227
+ */
228
+ export interface ConflictsResult {
229
+ conflicts: string[];
230
+ }
231
+ /**
232
+ * The websocket type used throughout the pool.
233
+ *
234
+ * The backend no longer speaks Socket.IO, so this is our {@link WispWebSocket}
235
+ * adapter rather than a Socket.IO `Socket`. The {@link ServerToClientEvents}
236
+ * and {@link ClientToServerEvents} interfaces above document the event names
237
+ * but are no longer enforced by the adapter's looser `(...args: any[])` API.
238
+ *
127
239
  * @internal
128
240
  */
129
- export type WispWebsocket = Socket<ServerToClientEvents, ClientToServerEvents>;
241
+ export type WispWebsocket = WispWebSocket;
130
242
  /**
131
243
  * A single worker in the Websocket Pool
132
244
  *
@@ -137,7 +249,9 @@ interface PoolWorker {
137
249
  socket: WispWebsocket;
138
250
  idx: number;
139
251
  token: string;
140
- ready: boolean;
252
+ url: string;
253
+ connected: boolean;
254
+ busy: boolean;
141
255
  done: boolean;
142
256
  logger: Logger;
143
257
  }
@@ -149,11 +263,36 @@ interface PoolWorker {
149
263
  * @internal
150
264
  */
151
265
  declare class PoolWorker {
266
+ private intentionalDisconnect;
267
+ private reconnectAttempts;
268
+ private readonly maxReconnectAttempts;
152
269
  constructor(pool: WebsocketPool);
270
+ private createSocket;
271
+ private start;
153
272
  connect(): Promise<void>;
273
+ private handleDisconnect;
154
274
  disconnect(): Promise<void>;
155
275
  private processWork;
156
276
  }
277
+ /**
278
+ * A queued unit of work plus a hook to reject it if it can never run (e.g. the
279
+ * pool has no live workers, or it waited too long to be picked up).
280
+ *
281
+ * @internal
282
+ */
283
+ interface QueueItem {
284
+ task: (worker: PoolWorker) => Promise<void>;
285
+ reject: (reason?: any) => void;
286
+ }
287
+ /**
288
+ * Fetches fresh websocket details (url + token) for reconnects.
289
+ *
290
+ * @internal
291
+ */
292
+ export type DetailsProvider = () => Promise<{
293
+ url: string;
294
+ token: string;
295
+ }>;
157
296
  /**
158
297
  * Struct used to manage a pool of WebSocket workers
159
298
  */
@@ -161,9 +300,12 @@ export interface WebsocketPool {
161
300
  workers: PoolWorker[];
162
301
  token: string;
163
302
  url: string;
303
+ origin: string;
164
304
  maxWorkers: number;
305
+ acquireTimeout: number;
306
+ refreshDetails?: DetailsProvider;
165
307
  logger: Logger;
166
- queue: ((worker: PoolWorker) => Promise<any>)[];
308
+ queue: QueueItem[];
167
309
  }
168
310
  /**
169
311
  * A pool of {@link PoolWorker}s
@@ -177,8 +319,10 @@ export interface WebsocketPool {
177
319
  * @internal
178
320
  */
179
321
  export declare class WebsocketPool {
180
- constructor(url: string, token: string);
322
+ constructor(url: string, token: string, origin: string, refreshDetails?: DetailsProvider);
181
323
  getWork(): ((worker: PoolWorker) => Promise<any>) | undefined;
324
+ private allWorkersDone;
325
+ onWorkerDone(): void;
182
326
  disconnect(): Promise<void>;
183
327
  run(work: (worker: PoolWorker) => Promise<any>): Promise<any>;
184
328
  }
@@ -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
+ }