@livedesk/hub 0.1.36 → 0.1.38

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,345 +1,314 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs';
3
- import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
4
-
5
- const CHUNK_BYTES = 512 * 1024;
6
- const JOB_RETENTION_MS = 10 * 60 * 1000;
7
- const COMMAND_RESULT_TIMEOUT_MS = 30 * 1000;
8
-
9
- function snapshot(job) {
10
- return {
11
- jobId: job.jobId,
12
- state: job.state,
13
- totalFiles: job.totalFiles,
14
- completedFiles: job.completedFiles,
15
- totalBytes: job.totalBytes,
16
- sentBytes: job.sentBytes,
17
- currentPath: job.currentPath || '',
18
- targetCount: job.deviceIds.length,
19
- failedTargets: [...job.failedTargets].map(deviceId => ({ deviceId, error: job.targetErrors.get(deviceId) || 'transfer-failed' })),
20
- error: job.error || null,
21
- createdAt: job.createdAt,
22
- updatedAt: job.updatedAt,
23
- completedAt: job.completedAt || null
24
- };
25
- }
26
-
27
- function markUpdated(job) {
28
- job.updatedAt = new Date().toISOString();
29
- }
30
-
31
- export class HubTransferJobs {
32
- constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS, getMaxFileSizeBytes = () => Number.MAX_SAFE_INTEGER } = {}) {
33
- this.filesystem = filesystem;
34
- this.remoteHub = remoteHub;
35
- this.maxConcurrent = Math.max(1, Number(maxConcurrent) || 2);
36
- this.commandResultTimeoutMs = Math.max(1_000, Number(commandResultTimeoutMs) || COMMAND_RESULT_TIMEOUT_MS);
37
- this.getMaxFileSizeBytes = typeof getMaxFileSizeBytes === 'function'
38
- ? getMaxFileSizeBytes
39
- : () => Number.MAX_SAFE_INTEGER;
40
- this.jobs = new Map();
41
- this.queue = [];
42
- this.running = 0;
43
- this.pendingCommands = new Map();
44
- }
45
-
46
- create({ itemIds = [], deviceIds = [], remoteDirectory = '', files = null, onComplete = null } = {}) {
47
- const targets = [...new Set((Array.isArray(deviceIds) ? deviceIds : []).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 240);
48
- if (targets.length === 0) throw new Error('no-target-devices');
49
- const job = {
50
- jobId: `job_${crypto.randomBytes(14).toString('base64url')}`,
51
- state: 'queued',
52
- itemIds: [...new Set((Array.isArray(itemIds) ? itemIds : []).map(value => String(value || '').trim()).filter(Boolean))],
53
- files,
54
- deviceIds: targets,
55
- remoteDirectory: String(remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600),
56
- totalFiles: 0,
57
- completedFiles: 0,
58
- totalBytes: 0,
59
- sentBytes: 0,
60
- currentPath: '',
61
- failedTargets: new Set(),
62
- targetErrors: new Map(),
63
- error: '',
64
- createdAt: new Date().toISOString(),
65
- updatedAt: new Date().toISOString(),
66
- completedAt: '',
67
- cancelled: false,
68
- currentStream: null,
69
- onComplete
70
- };
71
- this.jobs.set(job.jobId, job);
72
- this.queue.push(job);
73
- this.pump();
74
- return snapshot(job);
75
- }
76
-
77
- get(jobId) {
78
- this.prune();
79
- const job = this.jobs.get(String(jobId || ''));
80
- return job ? snapshot(job) : null;
81
- }
82
-
83
- list({ activeOnly = false } = {}) {
84
- this.prune();
85
- return [...this.jobs.values()]
86
- .filter(job => !activeOnly || ['scanning', 'queued', 'sending'].includes(job.state))
87
- .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
88
- .map(snapshot);
89
- }
90
-
91
- cancel(jobId) {
92
- const job = this.jobs.get(String(jobId || ''));
93
- if (!job) return null;
94
- if (['completed', 'partially-failed', 'failed', 'cancelled'].includes(job.state)) return snapshot(job);
95
- job.cancelled = true;
96
- job.state = 'cancelled';
97
- job.error = 'transfer-cancelled';
98
- if (job.currentStream) job.currentStream.destroy();
99
- this.finishPendingCommands(job.jobId, 'transfer-cancelled');
100
- markUpdated(job);
101
- return snapshot(job);
102
- }
103
-
104
- clear() {
105
- let cancelled = 0;
106
- for (const job of this.jobs.values()) {
107
- if (!['completed', 'partially-failed', 'failed', 'cancelled'].includes(job.state)) {
108
- this.cancel(job.jobId);
109
- cancelled += 1;
110
- }
111
- }
112
- const removed = this.jobs.size;
113
- this.jobs.clear();
114
- this.queue = [];
115
- this.pendingCommands.clear();
116
- return { cancelled, removed };
117
- }
118
-
119
- pump() {
120
- while (this.running < this.maxConcurrent && this.queue.length > 0) {
121
- const job = this.queue.shift();
122
- if (!job || job.cancelled) continue;
123
- this.running += 1;
124
- void this.run(job).finally(() => {
125
- this.running -= 1;
126
- this.pump();
127
- });
128
- }
129
- }
130
-
131
- async run(job) {
132
- try {
133
- job.state = 'scanning';
134
- markUpdated(job);
135
- const scan = job.files
136
- ? { files: job.files, totalBytes: job.files.reduce((sum, file) => sum + Number(file.size || 0), 0) }
137
- : await this.filesystem.scan(job.itemIds);
138
- if (job.cancelled) return;
139
- const maxFileSizeBytes = Math.max(1, Number(this.getMaxFileSizeBytes()) || 1);
140
- if (scan.files.some(file => Number(file.size || 0) > maxFileSizeBytes)) {
141
- throw new Error('file-transfer-file-too-large');
142
- }
143
- job.totalFiles = scan.files.length;
144
- job.totalBytes = scan.totalBytes;
145
- job.state = 'sending';
146
- markUpdated(job);
147
- if (scan.files.length === 0) {
148
- job.state = 'completed';
149
- job.completedAt = new Date().toISOString();
150
- markUpdated(job);
151
- await this.finishCallback(job, true);
152
- return;
153
- }
154
-
155
- for (const file of scan.files) {
156
- if (job.cancelled) return;
157
- job.currentPath = file.relativePath;
158
- markUpdated(job);
159
- await this.sendFile(job, file);
160
- job.completedFiles += 1;
161
- markUpdated(job);
162
- }
163
-
164
- if (job.cancelled) return;
165
- job.state = job.failedTargets.size === 0 ? 'completed' : 'partially-failed';
166
- job.completedAt = new Date().toISOString();
167
- job.currentPath = '';
168
- if (job.failedTargets.size > 0) job.error = 'one-or-more-targets-failed';
169
- markUpdated(job);
170
- await this.finishCallback(job, job.state === 'completed');
171
- } catch (error) {
172
- if (job.cancelled) return;
173
- job.state = job.failedTargets.size > 0 ? 'partially-failed' : 'failed';
174
- job.error = error instanceof Error ? error.message : String(error);
175
- job.completedAt = new Date().toISOString();
176
- job.currentPath = '';
177
- markUpdated(job);
178
- await this.finishCallback(job, false);
179
- }
180
- }
181
-
182
- async sendFile(job, file) {
183
- const activeTargets = () => job.deviceIds.filter(deviceId => !job.failedTargets.has(deviceId));
184
- if (Number(file.size || 0) === 0) {
185
- const sha256 = crypto.createHash('sha256').update(Buffer.alloc(0)).digest('hex');
186
- await this.sendChunk(job, file, 0, Buffer.alloc(0), true, activeTargets(), sha256);
187
- return;
188
- }
189
- const stream = fs.createReadStream(file.absolutePath, { highWaterMark: CHUNK_BYTES });
190
- const hasher = crypto.createHash('sha256');
191
- job.currentStream = stream;
192
- let offset = 0;
193
- try {
194
- for await (const chunk of stream) {
195
- if (job.cancelled) return;
196
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
197
- const final = offset + buffer.length >= Number(file.size || 0);
198
- hasher.update(buffer);
199
- const sha256 = final ? hasher.digest('hex') : '';
200
- const targets = activeTargets();
201
- if (targets.length === 0) throw new Error('all-targets-failed');
202
- await this.sendChunk(job, file, offset, buffer, final, targets, sha256);
203
- offset += buffer.length;
204
- job.sentBytes += buffer.length;
205
- markUpdated(job);
206
- await yieldToEventLoop();
207
- }
208
- } finally {
209
- job.currentStream = null;
210
- }
211
- }
212
-
213
- async sendChunk(job, file, offset, buffer, final, targets, sha256 = '') {
214
- const outcomes = await Promise.all(targets.map(async deviceId => {
215
- if (job.cancelled) return { deviceId, ok: false, error: 'transfer-cancelled' };
216
- const commandId = crypto.randomUUID();
217
- const pending = this.waitForCommandResult(job.jobId, deviceId, commandId);
218
- const result = this.remoteHub.sendCommand(deviceId, {
219
- command: 'file.transfer.chunk',
220
- commandId,
221
- payload: {
222
- transferId: job.jobId,
223
- remoteDirectory: job.remoteDirectory,
224
- name: file.name,
225
- relativePath: file.relativePath,
226
- offset,
227
- totalBytes: Number(file.size || 0),
228
- final,
229
- sha256,
230
- dataBase64: buffer.toString('base64'),
231
- lastModified: Number(file.modifiedMs || 0) || 0,
232
- requestedAt: new Date().toISOString()
233
- }
234
- });
235
- if (!result?.ok) {
236
- this.settlePendingCommand(commandId, {
237
- ok: false,
238
- error: String(result?.error || 'device-not-connected')
239
- });
240
- }
241
- return { deviceId, ...await pending };
242
- }));
243
-
244
- for (const outcome of outcomes) {
245
- if (outcome.ok || outcome.error === 'transfer-cancelled') continue;
246
- job.failedTargets.add(outcome.deviceId);
247
- job.targetErrors.set(outcome.deviceId, outcome.error || 'transfer-failed');
248
- }
249
- if (!job.cancelled && outcomes.length > 0 && outcomes.every(outcome => !outcome.ok)) {
250
- throw new Error('all-targets-failed');
251
- }
252
- }
253
-
254
- waitForCommandResult(jobId, deviceId, commandId) {
255
- return new Promise(resolve => {
256
- const timer = setTimeout(() => {
257
- this.settlePendingCommand(commandId, {
258
- ok: false,
259
- error: 'file-transfer-ack-timeout'
260
- });
261
- }, this.commandResultTimeoutMs);
262
- timer.unref?.();
263
- this.pendingCommands.set(commandId, {
264
- jobId,
265
- deviceId,
266
- timer,
267
- resolve
268
- });
269
- });
270
- }
271
-
272
- settlePendingCommand(commandId, outcome) {
273
- const pending = this.pendingCommands.get(String(commandId || ''));
274
- if (!pending) return false;
275
- this.pendingCommands.delete(commandId);
276
- clearTimeout(pending.timer);
277
- pending.resolve(outcome);
278
- return true;
279
- }
280
-
281
- finishPendingCommands(jobId, error) {
282
- for (const [commandId, pending] of this.pendingCommands) {
283
- if (pending.jobId !== jobId) continue;
284
- this.settlePendingCommand(commandId, { ok: false, error });
285
- }
286
- }
287
-
288
- handleRemoteEvent(type, event) {
289
- if (type === 'RemoteCommandResult') {
290
- const commandId = String(event?.commandId || '');
291
- const pending = this.pendingCommands.get(commandId);
292
- if (!pending) return;
293
- const eventDeviceId = String(event?.deviceId || event?.device?.deviceId || '');
294
- if (eventDeviceId && eventDeviceId !== pending.deviceId) return;
295
- const result = event?.result;
296
- const error = String(
297
- event?.error
298
- || (result?.ok === false || result?.status === 'failed' || result?.status === 'rejected'
299
- ? result?.error || 'file-transfer-rejected'
300
- : '')
301
- );
302
- this.settlePendingCommand(commandId, error
303
- ? { ok: false, error }
304
- : { ok: true, result });
305
- return;
306
- }
307
-
308
- if (type !== 'RemoteDeviceDisconnected') return;
309
- const deviceId = String(event?.deviceId || event?.device?.deviceId || '');
310
- if (!deviceId) return;
311
- for (const [commandId, pending] of this.pendingCommands) {
312
- if (pending.deviceId !== deviceId) continue;
313
- this.settlePendingCommand(commandId, {
314
- ok: false,
315
- error: 'device-disconnected'
316
- });
317
- }
318
- }
319
-
320
- async finishCallback(job, completed) {
321
- if (typeof job.onComplete !== 'function') return;
322
- try {
323
- await job.onComplete({ completed, job: snapshot(job) });
324
- } catch (error) {
325
- // A completion callback can be the durable security-audit gate. Never
326
- // leave a job looking successful when that final record was not stored.
327
- job.state = 'failed';
328
- job.error = `completion-callback-failed:${error instanceof Error ? error.message : String(error)}`;
329
- job.completedAt ||= new Date().toISOString();
330
- markUpdated(job);
331
- }
332
- }
333
-
334
- prune() {
335
- const threshold = Date.now() - JOB_RETENTION_MS;
336
- for (const [id, job] of this.jobs) {
337
- const createdAt = Date.parse(job.createdAt);
338
- if (job.completedAt && Number.isFinite(createdAt) && createdAt < threshold) this.jobs.delete(id);
339
- }
340
- }
341
- }
342
-
343
- export function createHubTransferJobs(options) {
344
- return new HubTransferJobs(options);
345
- }
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
4
+
5
+ const CHUNK_BYTES = 512 * 1024;
6
+ const JOB_RETENTION_MS = 10 * 60 * 1000;
7
+ const COMMAND_RESULT_TIMEOUT_MS = 30 * 1000;
8
+
9
+ function snapshot(job) {
10
+ return {
11
+ jobId: job.jobId,
12
+ state: job.state,
13
+ totalFiles: job.totalFiles,
14
+ completedFiles: job.completedFiles,
15
+ totalBytes: job.totalBytes,
16
+ sentBytes: job.sentBytes,
17
+ currentPath: job.currentPath || '',
18
+ targetCount: job.deviceIds.length,
19
+ failedTargets: [...job.failedTargets].map(deviceId => ({ deviceId, error: job.targetErrors.get(deviceId) || 'transfer-failed' })),
20
+ error: job.error || null,
21
+ createdAt: job.createdAt,
22
+ updatedAt: job.updatedAt,
23
+ completedAt: job.completedAt || null
24
+ };
25
+ }
26
+
27
+ function markUpdated(job) {
28
+ job.updatedAt = new Date().toISOString();
29
+ }
30
+
31
+ export class HubTransferJobs {
32
+ constructor({ filesystem, remoteHub, maxConcurrent = 2, commandResultTimeoutMs = COMMAND_RESULT_TIMEOUT_MS } = {}) {
33
+ this.filesystem = filesystem;
34
+ this.remoteHub = remoteHub;
35
+ this.maxConcurrent = Math.max(1, Number(maxConcurrent) || 2);
36
+ this.commandResultTimeoutMs = Math.max(1_000, Number(commandResultTimeoutMs) || COMMAND_RESULT_TIMEOUT_MS);
37
+ this.jobs = new Map();
38
+ this.queue = [];
39
+ this.running = 0;
40
+ this.pendingCommands = new Map();
41
+ }
42
+
43
+ create({ itemIds = [], deviceIds = [], remoteDirectory = '', files = null, onComplete = null } = {}) {
44
+ const targets = [...new Set((Array.isArray(deviceIds) ? deviceIds : []).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 240);
45
+ if (targets.length === 0) throw new Error('no-target-devices');
46
+ const job = {
47
+ jobId: `job_${crypto.randomBytes(14).toString('base64url')}`,
48
+ state: 'queued',
49
+ itemIds: [...new Set((Array.isArray(itemIds) ? itemIds : []).map(value => String(value || '').trim()).filter(Boolean))],
50
+ files,
51
+ deviceIds: targets,
52
+ remoteDirectory: String(remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600),
53
+ totalFiles: 0,
54
+ completedFiles: 0,
55
+ totalBytes: 0,
56
+ sentBytes: 0,
57
+ currentPath: '',
58
+ failedTargets: new Set(),
59
+ targetErrors: new Map(),
60
+ error: '',
61
+ createdAt: new Date().toISOString(),
62
+ updatedAt: new Date().toISOString(),
63
+ completedAt: '',
64
+ cancelled: false,
65
+ currentStream: null,
66
+ onComplete
67
+ };
68
+ this.jobs.set(job.jobId, job);
69
+ this.queue.push(job);
70
+ this.pump();
71
+ return snapshot(job);
72
+ }
73
+
74
+ get(jobId) {
75
+ this.prune();
76
+ const job = this.jobs.get(String(jobId || ''));
77
+ return job ? snapshot(job) : null;
78
+ }
79
+
80
+ list({ activeOnly = false } = {}) {
81
+ this.prune();
82
+ return [...this.jobs.values()]
83
+ .filter(job => !activeOnly || ['scanning', 'queued', 'sending'].includes(job.state))
84
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
85
+ .map(snapshot);
86
+ }
87
+
88
+ cancel(jobId) {
89
+ const job = this.jobs.get(String(jobId || ''));
90
+ if (!job) return null;
91
+ if (['completed', 'partially-failed', 'failed', 'cancelled'].includes(job.state)) return snapshot(job);
92
+ job.cancelled = true;
93
+ job.state = 'cancelled';
94
+ job.error = 'transfer-cancelled';
95
+ if (job.currentStream) job.currentStream.destroy();
96
+ this.finishPendingCommands(job.jobId, 'transfer-cancelled');
97
+ markUpdated(job);
98
+ return snapshot(job);
99
+ }
100
+
101
+ pump() {
102
+ while (this.running < this.maxConcurrent && this.queue.length > 0) {
103
+ const job = this.queue.shift();
104
+ if (!job || job.cancelled) continue;
105
+ this.running += 1;
106
+ void this.run(job).finally(() => {
107
+ this.running -= 1;
108
+ this.pump();
109
+ });
110
+ }
111
+ }
112
+
113
+ async run(job) {
114
+ try {
115
+ job.state = 'scanning';
116
+ markUpdated(job);
117
+ const scan = job.files
118
+ ? { files: job.files, totalBytes: job.files.reduce((sum, file) => sum + Number(file.size || 0), 0) }
119
+ : await this.filesystem.scan(job.itemIds);
120
+ if (job.cancelled) return;
121
+ job.totalFiles = scan.files.length;
122
+ job.totalBytes = scan.totalBytes;
123
+ job.state = 'sending';
124
+ markUpdated(job);
125
+ if (scan.files.length === 0) {
126
+ job.state = 'completed';
127
+ job.completedAt = new Date().toISOString();
128
+ markUpdated(job);
129
+ await this.finishCallback(job, true);
130
+ return;
131
+ }
132
+
133
+ for (const file of scan.files) {
134
+ if (job.cancelled) return;
135
+ job.currentPath = file.relativePath;
136
+ markUpdated(job);
137
+ await this.sendFile(job, file);
138
+ job.completedFiles += 1;
139
+ markUpdated(job);
140
+ }
141
+
142
+ if (job.cancelled) return;
143
+ job.state = job.failedTargets.size === 0 ? 'completed' : 'partially-failed';
144
+ job.completedAt = new Date().toISOString();
145
+ job.currentPath = '';
146
+ if (job.failedTargets.size > 0) job.error = 'one-or-more-targets-failed';
147
+ markUpdated(job);
148
+ await this.finishCallback(job, job.state === 'completed');
149
+ } catch (error) {
150
+ if (job.cancelled) return;
151
+ job.state = job.failedTargets.size > 0 ? 'partially-failed' : 'failed';
152
+ job.error = error instanceof Error ? error.message : String(error);
153
+ job.completedAt = new Date().toISOString();
154
+ job.currentPath = '';
155
+ markUpdated(job);
156
+ await this.finishCallback(job, false);
157
+ }
158
+ }
159
+
160
+ async sendFile(job, file) {
161
+ const activeTargets = () => job.deviceIds.filter(deviceId => !job.failedTargets.has(deviceId));
162
+ if (Number(file.size || 0) === 0) {
163
+ await this.sendChunk(job, file, 0, Buffer.alloc(0), true, activeTargets());
164
+ return;
165
+ }
166
+ const stream = fs.createReadStream(file.absolutePath, { highWaterMark: CHUNK_BYTES });
167
+ job.currentStream = stream;
168
+ let offset = 0;
169
+ try {
170
+ for await (const chunk of stream) {
171
+ if (job.cancelled) return;
172
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
173
+ const final = offset + buffer.length >= Number(file.size || 0);
174
+ const targets = activeTargets();
175
+ if (targets.length === 0) throw new Error('all-targets-failed');
176
+ await this.sendChunk(job, file, offset, buffer, final, targets);
177
+ offset += buffer.length;
178
+ job.sentBytes += buffer.length;
179
+ markUpdated(job);
180
+ await yieldToEventLoop();
181
+ }
182
+ } finally {
183
+ job.currentStream = null;
184
+ }
185
+ }
186
+
187
+ async sendChunk(job, file, offset, buffer, final, targets) {
188
+ const outcomes = await Promise.all(targets.map(async deviceId => {
189
+ if (job.cancelled) return { deviceId, ok: false, error: 'transfer-cancelled' };
190
+ const commandId = crypto.randomUUID();
191
+ const pending = this.waitForCommandResult(job.jobId, deviceId, commandId);
192
+ const result = this.remoteHub.sendCommand(deviceId, {
193
+ command: 'file.transfer.chunk',
194
+ commandId,
195
+ payload: {
196
+ transferId: job.jobId,
197
+ remoteDirectory: job.remoteDirectory,
198
+ name: file.name,
199
+ relativePath: file.relativePath,
200
+ offset,
201
+ totalBytes: Number(file.size || 0),
202
+ final,
203
+ dataBase64: buffer.toString('base64'),
204
+ lastModified: Number(file.modifiedMs || 0) || 0,
205
+ requestedAt: new Date().toISOString()
206
+ }
207
+ });
208
+ if (!result?.ok) {
209
+ this.settlePendingCommand(commandId, {
210
+ ok: false,
211
+ error: String(result?.error || 'device-not-connected')
212
+ });
213
+ }
214
+ return { deviceId, ...await pending };
215
+ }));
216
+
217
+ for (const outcome of outcomes) {
218
+ if (outcome.ok || outcome.error === 'transfer-cancelled') continue;
219
+ job.failedTargets.add(outcome.deviceId);
220
+ job.targetErrors.set(outcome.deviceId, outcome.error || 'transfer-failed');
221
+ }
222
+ if (!job.cancelled && outcomes.length > 0 && outcomes.every(outcome => !outcome.ok)) {
223
+ throw new Error('all-targets-failed');
224
+ }
225
+ }
226
+
227
+ waitForCommandResult(jobId, deviceId, commandId) {
228
+ return new Promise(resolve => {
229
+ const timer = setTimeout(() => {
230
+ this.settlePendingCommand(commandId, {
231
+ ok: false,
232
+ error: 'file-transfer-ack-timeout'
233
+ });
234
+ }, this.commandResultTimeoutMs);
235
+ timer.unref?.();
236
+ this.pendingCommands.set(commandId, {
237
+ jobId,
238
+ deviceId,
239
+ timer,
240
+ resolve
241
+ });
242
+ });
243
+ }
244
+
245
+ settlePendingCommand(commandId, outcome) {
246
+ const pending = this.pendingCommands.get(String(commandId || ''));
247
+ if (!pending) return false;
248
+ this.pendingCommands.delete(commandId);
249
+ clearTimeout(pending.timer);
250
+ pending.resolve(outcome);
251
+ return true;
252
+ }
253
+
254
+ finishPendingCommands(jobId, error) {
255
+ for (const [commandId, pending] of this.pendingCommands) {
256
+ if (pending.jobId !== jobId) continue;
257
+ this.settlePendingCommand(commandId, { ok: false, error });
258
+ }
259
+ }
260
+
261
+ handleRemoteEvent(type, event) {
262
+ if (type === 'RemoteCommandResult') {
263
+ const commandId = String(event?.commandId || '');
264
+ const pending = this.pendingCommands.get(commandId);
265
+ if (!pending) return;
266
+ const eventDeviceId = String(event?.deviceId || event?.device?.deviceId || '');
267
+ if (eventDeviceId && eventDeviceId !== pending.deviceId) return;
268
+ const result = event?.result;
269
+ const error = String(
270
+ event?.error
271
+ || (result?.ok === false || result?.status === 'failed' || result?.status === 'rejected'
272
+ ? result?.error || 'file-transfer-rejected'
273
+ : '')
274
+ );
275
+ this.settlePendingCommand(commandId, error
276
+ ? { ok: false, error }
277
+ : { ok: true, result });
278
+ return;
279
+ }
280
+
281
+ if (type !== 'RemoteDeviceDisconnected') return;
282
+ const deviceId = String(event?.deviceId || event?.device?.deviceId || '');
283
+ if (!deviceId) return;
284
+ for (const [commandId, pending] of this.pendingCommands) {
285
+ if (pending.deviceId !== deviceId) continue;
286
+ this.settlePendingCommand(commandId, {
287
+ ok: false,
288
+ error: 'device-disconnected'
289
+ });
290
+ }
291
+ }
292
+
293
+ async finishCallback(job, completed) {
294
+ if (typeof job.onComplete !== 'function') return;
295
+ try {
296
+ await job.onComplete({ completed, job: snapshot(job) });
297
+ } catch (error) {
298
+ job.error = error instanceof Error ? error.message : String(error);
299
+ markUpdated(job);
300
+ }
301
+ }
302
+
303
+ prune() {
304
+ const threshold = Date.now() - JOB_RETENTION_MS;
305
+ for (const [id, job] of this.jobs) {
306
+ const createdAt = Date.parse(job.createdAt);
307
+ if (job.completedAt && Number.isFinite(createdAt) && createdAt < threshold) this.jobs.delete(id);
308
+ }
309
+ }
310
+ }
311
+
312
+ export function createHubTransferJobs(options) {
313
+ return new HubTransferJobs(options);
314
+ }