@johnhenry/wsh 0.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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * WshFileTransfer — dedicated stream file transfer (scp-like) over a wsh connection.
3
+ *
4
+ * Uploads and downloads files using the wsh channel protocol with 'file' kind
5
+ * channels. Data flows over a dedicated bidirectional stream in 64KB chunks.
6
+ */
7
+
8
+ import { MSG, open, CHANNEL_KIND } from './messages.mjs';
9
+
10
+ /** Transfer chunk size: 64KB. */
11
+ const CHUNK_SIZE = 65536;
12
+
13
+ /** Default timeout for waiting on control messages (30 seconds). */
14
+ const RESPONSE_TIMEOUT_MS = 30_000;
15
+
16
+ export class WshFileTransfer {
17
+ /** @type {{ sendControl: function, openStream: function, onControl: function }} */
18
+ #client;
19
+
20
+ /**
21
+ * @param {object} client - A WshClient or any transport object exposing:
22
+ * - sendControl(msg): send a control message
23
+ * - openStream(): open a new bidirectional stream
24
+ * - onControl: settable callback for incoming control messages
25
+ * (or a method to add a listener — see _waitForMessage)
26
+ */
27
+ constructor(client) {
28
+ if (!client) throw new Error('WshFileTransfer requires a client');
29
+ this.#client = client;
30
+ }
31
+
32
+ /**
33
+ * Upload data to a remote path.
34
+ *
35
+ * Protocol flow:
36
+ * 1. Send OPEN { kind: 'file', command: 'upload', path }
37
+ * 2. Receive OPEN_OK { channel_id, stream_ids }
38
+ * 3. Write data to dedicated stream in 64KB chunks
39
+ * 4. Close the write side of the stream
40
+ * 5. Wait for EXIT message with status code
41
+ *
42
+ * @param {Uint8Array | ArrayBuffer} data - File content to upload
43
+ * @param {string} remotePath - Destination path on the remote host
44
+ * @param {object} [opts]
45
+ * @param {function} [opts.onProgress] - Called with { sent: number, total: number }
46
+ * @param {number} [opts.timeout] - Timeout in ms for server responses
47
+ * @returns {Promise<{ success: boolean, bytesTransferred: number }>}
48
+ */
49
+ async upload(data, remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
50
+ if (typeof this.#client.upload === 'function') {
51
+ await this.#client.upload(data, remotePath, {
52
+ onProgress: (value) => {
53
+ if (typeof value === 'number') {
54
+ onProgress?.({ sent: value, total: data.byteLength ?? data.length ?? 0 });
55
+ return;
56
+ }
57
+ onProgress?.(value);
58
+ },
59
+ timeout,
60
+ });
61
+ const bytes = data instanceof Uint8Array ? data.byteLength : data.byteLength ?? data.length ?? 0;
62
+ return { success: true, bytesTransferred: bytes };
63
+ }
64
+
65
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
66
+ if (!(bytes instanceof Uint8Array)) {
67
+ throw new TypeError('Data must be a Uint8Array or ArrayBuffer');
68
+ }
69
+ if (!remotePath || typeof remotePath !== 'string') {
70
+ throw new Error('remotePath is required');
71
+ }
72
+
73
+ // 1. Request a file upload channel
74
+ const openMsg = open({
75
+ kind: CHANNEL_KIND.FILE,
76
+ command: 'upload',
77
+ });
78
+ openMsg.path = remotePath;
79
+ openMsg.size = bytes.length;
80
+
81
+ await this.#client.sendControl(openMsg);
82
+
83
+ // 2. Wait for OPEN_OK or OPEN_FAIL
84
+ const response = await this._waitForMessage(
85
+ (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
86
+ timeout
87
+ );
88
+
89
+ if (response.type === MSG.OPEN_FAIL) {
90
+ throw new Error(`Upload rejected: ${response.reason || 'unknown reason'}`);
91
+ }
92
+
93
+ const channelId = response.channel_id;
94
+
95
+ // 3. Open a dedicated data stream and write in chunks
96
+ const stream = await this.#client.openStream();
97
+ const writer = stream.writable.getWriter();
98
+ let sent = 0;
99
+
100
+ try {
101
+ while (sent < bytes.length) {
102
+ const end = Math.min(sent + CHUNK_SIZE, bytes.length);
103
+ const chunk = bytes.subarray(sent, end);
104
+ await writer.write(chunk);
105
+ sent = end;
106
+ try {
107
+ onProgress?.({ sent, total: bytes.length });
108
+ } catch { /* ignore callback errors */ }
109
+ }
110
+ } finally {
111
+ // 4. Close the write side to signal end of data
112
+ try {
113
+ await writer.close();
114
+ } catch {
115
+ // Stream may already be closed on error
116
+ }
117
+ }
118
+
119
+ // 5. Wait for EXIT confirmation
120
+ const exitMsg = await this._waitForMessage(
121
+ (msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
122
+ timeout
123
+ );
124
+
125
+ const success = exitMsg.code === 0;
126
+ if (!success) {
127
+ throw new Error(`Upload failed with exit code ${exitMsg.code}`);
128
+ }
129
+
130
+ return { success, bytesTransferred: sent };
131
+ }
132
+
133
+ /**
134
+ * Download a file from a remote path.
135
+ *
136
+ * Protocol flow:
137
+ * 1. Send OPEN { kind: 'file', command: 'download', path }
138
+ * 2. Receive OPEN_OK { channel_id, stream_ids }
139
+ * 3. Read from the dedicated stream, accumulating chunks
140
+ * 4. Return complete file content as Uint8Array
141
+ *
142
+ * @param {string} remotePath - File path on the remote host
143
+ * @param {object} [opts]
144
+ * @param {function} [opts.onProgress] - Called with { received: number, total?: number }
145
+ * @param {number} [opts.timeout] - Timeout in ms for server responses
146
+ * @returns {Promise<Uint8Array>} File content
147
+ */
148
+ async download(remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
149
+ if (typeof this.#client.download === 'function') {
150
+ return await this.#client.download(remotePath, { onProgress, timeout });
151
+ }
152
+
153
+ if (!remotePath || typeof remotePath !== 'string') {
154
+ throw new Error('remotePath is required');
155
+ }
156
+
157
+ // 1. Request a file download channel
158
+ const openMsg = open({
159
+ kind: CHANNEL_KIND.FILE,
160
+ command: 'download',
161
+ });
162
+ openMsg.path = remotePath;
163
+
164
+ await this.#client.sendControl(openMsg);
165
+
166
+ // 2. Wait for OPEN_OK or OPEN_FAIL
167
+ const response = await this._waitForMessage(
168
+ (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
169
+ timeout
170
+ );
171
+
172
+ if (response.type === MSG.OPEN_FAIL) {
173
+ throw new Error(`Download rejected: ${response.reason || 'unknown reason'}`);
174
+ }
175
+
176
+ const channelId = response.channel_id;
177
+ const totalSize = response.size; // Server may include total file size
178
+
179
+ // 3. Open a dedicated data stream and read chunks
180
+ const stream = await this.#client.openStream();
181
+ const reader = stream.readable.getReader();
182
+ const chunks = [];
183
+ let received = 0;
184
+
185
+ try {
186
+ while (true) {
187
+ const { done, value } = await reader.read();
188
+ if (done) break;
189
+
190
+ chunks.push(value);
191
+ received += value.length;
192
+
193
+ try {
194
+ onProgress?.({ received, total: totalSize });
195
+ } catch { /* ignore callback errors */ }
196
+ }
197
+ } finally {
198
+ reader.releaseLock();
199
+ }
200
+
201
+ // 4. Wait for EXIT confirmation
202
+ try {
203
+ const exitMsg = await this._waitForMessage(
204
+ (msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
205
+ timeout
206
+ );
207
+ if (exitMsg.code !== 0) {
208
+ throw new Error(`Download failed with exit code ${exitMsg.code}`);
209
+ }
210
+ } catch (err) {
211
+ // If the stream closed cleanly and we got data, the EXIT may have
212
+ // already been consumed or the server may not send one for downloads.
213
+ // Only rethrow if we have no data.
214
+ if (received === 0) throw err;
215
+ }
216
+
217
+ // Concatenate chunks into a single Uint8Array
218
+ return this._concatChunks(chunks, received);
219
+ }
220
+
221
+ /**
222
+ * List files at a remote path by executing `ls -la` via the client.
223
+ *
224
+ * This uses a standard exec channel rather than the file transfer protocol,
225
+ * parsing the output of `ls -la` into structured entries.
226
+ *
227
+ * @param {string} remotePath - Directory path on the remote host
228
+ * @returns {Promise<Array<{ name: string, size: number, modified: string, type: string }>>}
229
+ */
230
+ async list(remotePath) {
231
+ if (!remotePath || typeof remotePath !== 'string') {
232
+ throw new Error('remotePath is required');
233
+ }
234
+
235
+ // Use an exec channel to run ls
236
+ const openMsg = open({
237
+ kind: CHANNEL_KIND.EXEC,
238
+ command: `ls -la ${this._shellEscape(remotePath)}`,
239
+ });
240
+
241
+ await this.#client.sendControl(openMsg);
242
+
243
+ const response = await this._waitForMessage(
244
+ (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
245
+ RESPONSE_TIMEOUT_MS
246
+ );
247
+
248
+ if (response.type === MSG.OPEN_FAIL) {
249
+ throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
250
+ }
251
+
252
+ // Read the output stream
253
+ const stream = await this.#client.openStream();
254
+ const reader = stream.readable.getReader();
255
+ const chunks = [];
256
+
257
+ try {
258
+ while (true) {
259
+ const { done, value } = await reader.read();
260
+ if (done) break;
261
+ chunks.push(value);
262
+ }
263
+ } finally {
264
+ reader.releaseLock();
265
+ }
266
+
267
+ const output = new TextDecoder().decode(this._concatChunks(chunks));
268
+ return this._parseLsOutput(output);
269
+ }
270
+
271
+ // ── Internal helpers ──────────────────────────────────────────────
272
+
273
+ /**
274
+ * Wait for a control message matching a predicate.
275
+ *
276
+ * Temporarily hooks into the client's control message flow and resolves
277
+ * when a matching message arrives (or rejects on timeout).
278
+ *
279
+ * @param {function(object): boolean} predicate
280
+ * @param {number} timeoutMs
281
+ * @returns {Promise<object>}
282
+ */
283
+ _waitForMessage(predicate, timeoutMs) {
284
+ return new Promise((resolve, reject) => {
285
+ let timer = null;
286
+ let settled = false;
287
+
288
+ const cleanup = () => {
289
+ settled = true;
290
+ if (timer !== null) clearTimeout(timer);
291
+ // Remove listener
292
+ if (this.#client.removeControlListener) {
293
+ this.#client.removeControlListener(listener);
294
+ } else if (this.#client._controlListeners) {
295
+ const idx = this.#client._controlListeners.indexOf(listener);
296
+ if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
297
+ }
298
+ };
299
+
300
+ const listener = (msg) => {
301
+ if (settled) return;
302
+ if (predicate(msg)) {
303
+ cleanup();
304
+ resolve(msg);
305
+ }
306
+ };
307
+
308
+ // Register listener on the client
309
+ if (this.#client.addControlListener) {
310
+ this.#client.addControlListener(listener);
311
+ } else if (this.#client._controlListeners) {
312
+ this.#client._controlListeners.push(listener);
313
+ } else {
314
+ // Fallback: wrap existing onControl
315
+ const prev = this.#client.onControl;
316
+ this.#client.onControl = (msg) => {
317
+ prev?.(msg);
318
+ listener(msg);
319
+ };
320
+ }
321
+
322
+ timer = setTimeout(() => {
323
+ if (!settled) {
324
+ cleanup();
325
+ reject(new Error(`Timeout waiting for response (${timeoutMs}ms)`));
326
+ }
327
+ }, timeoutMs);
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Concatenate an array of Uint8Arrays into one.
333
+ *
334
+ * @param {Uint8Array[]} chunks
335
+ * @param {number} [totalLength] - Pre-computed total, avoids re-summing
336
+ * @returns {Uint8Array}
337
+ */
338
+ _concatChunks(chunks, totalLength) {
339
+ if (chunks.length === 0) return new Uint8Array(0);
340
+ if (chunks.length === 1) return chunks[0];
341
+
342
+ const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
343
+ const result = new Uint8Array(total);
344
+ let offset = 0;
345
+ for (const chunk of chunks) {
346
+ result.set(chunk, offset);
347
+ offset += chunk.length;
348
+ }
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * Parse the output of `ls -la` into structured entries.
354
+ *
355
+ * Expected format per line:
356
+ * drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
357
+ * -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
358
+ *
359
+ * @param {string} output
360
+ * @returns {Array<{ name: string, size: number, modified: string, type: string }>}
361
+ */
362
+ _parseLsOutput(output) {
363
+ const lines = output.split('\n').filter((l) => l.trim());
364
+ const entries = [];
365
+
366
+ for (const line of lines) {
367
+ // Skip the "total" header line
368
+ if (line.startsWith('total ')) continue;
369
+
370
+ // ls -la columns: perms links owner group size month day time name
371
+ const parts = line.split(/\s+/);
372
+ if (parts.length < 9) continue;
373
+
374
+ const perms = parts[0];
375
+ const size = parseInt(parts[4], 10) || 0;
376
+ const month = parts[5];
377
+ const day = parts[6];
378
+ const time = parts[7];
379
+ // Name may contain spaces — rejoin everything from column 8 onward
380
+ const name = parts.slice(8).join(' ');
381
+
382
+ // Skip . and .. entries
383
+ if (name === '.' || name === '..') continue;
384
+
385
+ // Determine type from permission string first character
386
+ let type = 'file';
387
+ if (perms.startsWith('d')) type = 'directory';
388
+ else if (perms.startsWith('l')) type = 'symlink';
389
+ else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
390
+ else if (perms.startsWith('p')) type = 'pipe';
391
+ else if (perms.startsWith('s')) type = 'socket';
392
+
393
+ entries.push({
394
+ name,
395
+ size,
396
+ modified: `${month} ${day} ${time}`,
397
+ type,
398
+ });
399
+ }
400
+
401
+ return entries;
402
+ }
403
+
404
+ /**
405
+ * Basic shell escaping for a path argument.
406
+ * @param {string} str
407
+ * @returns {string}
408
+ */
409
+ _shellEscape(str) {
410
+ // Wrap in single quotes, escaping any embedded single quotes
411
+ return "'" + str.replace(/'/g, "'\\''") + "'";
412
+ }
413
+ }