@livedesk/hub 0.1.0 → 0.1.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.
@@ -0,0 +1,229 @@
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
+
8
+ function snapshot(job) {
9
+ return {
10
+ jobId: job.jobId,
11
+ state: job.state,
12
+ totalFiles: job.totalFiles,
13
+ completedFiles: job.completedFiles,
14
+ totalBytes: job.totalBytes,
15
+ sentBytes: job.sentBytes,
16
+ currentPath: job.currentPath || '',
17
+ targetCount: job.deviceIds.length,
18
+ failedTargets: [...job.failedTargets].map(deviceId => ({ deviceId, error: job.targetErrors.get(deviceId) || 'transfer-failed' })),
19
+ error: job.error || null,
20
+ createdAt: job.createdAt,
21
+ updatedAt: job.updatedAt,
22
+ completedAt: job.completedAt || null
23
+ };
24
+ }
25
+
26
+ function markUpdated(job) {
27
+ job.updatedAt = new Date().toISOString();
28
+ }
29
+
30
+ export class HubTransferJobs {
31
+ constructor({ filesystem, remoteHub, maxConcurrent = 2 } = {}) {
32
+ this.filesystem = filesystem;
33
+ this.remoteHub = remoteHub;
34
+ this.maxConcurrent = Math.max(1, Number(maxConcurrent) || 2);
35
+ this.jobs = new Map();
36
+ this.queue = [];
37
+ this.running = 0;
38
+ }
39
+
40
+ create({ itemIds = [], deviceIds = [], remoteDirectory = '', files = null, onComplete = null } = {}) {
41
+ const targets = [...new Set((Array.isArray(deviceIds) ? deviceIds : []).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 240);
42
+ if (targets.length === 0) throw new Error('no-target-devices');
43
+ const job = {
44
+ jobId: `job_${crypto.randomBytes(14).toString('base64url')}`,
45
+ state: 'queued',
46
+ itemIds: [...new Set((Array.isArray(itemIds) ? itemIds : []).map(value => String(value || '').trim()).filter(Boolean))],
47
+ files,
48
+ deviceIds: targets,
49
+ remoteDirectory: String(remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600),
50
+ totalFiles: 0,
51
+ completedFiles: 0,
52
+ totalBytes: 0,
53
+ sentBytes: 0,
54
+ currentPath: '',
55
+ failedTargets: new Set(),
56
+ targetErrors: new Map(),
57
+ error: '',
58
+ createdAt: new Date().toISOString(),
59
+ updatedAt: new Date().toISOString(),
60
+ completedAt: '',
61
+ cancelled: false,
62
+ currentStream: null,
63
+ onComplete
64
+ };
65
+ this.jobs.set(job.jobId, job);
66
+ this.queue.push(job);
67
+ this.pump();
68
+ return snapshot(job);
69
+ }
70
+
71
+ get(jobId) {
72
+ this.prune();
73
+ const job = this.jobs.get(String(jobId || ''));
74
+ return job ? snapshot(job) : null;
75
+ }
76
+
77
+ list({ activeOnly = false } = {}) {
78
+ this.prune();
79
+ return [...this.jobs.values()]
80
+ .filter(job => !activeOnly || ['scanning', 'queued', 'sending'].includes(job.state))
81
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
82
+ .map(snapshot);
83
+ }
84
+
85
+ cancel(jobId) {
86
+ const job = this.jobs.get(String(jobId || ''));
87
+ if (!job) return null;
88
+ if (['completed', 'partially-failed', 'failed', 'cancelled'].includes(job.state)) return snapshot(job);
89
+ job.cancelled = true;
90
+ job.state = 'cancelled';
91
+ job.error = 'transfer-cancelled';
92
+ if (job.currentStream) job.currentStream.destroy();
93
+ markUpdated(job);
94
+ return snapshot(job);
95
+ }
96
+
97
+ pump() {
98
+ while (this.running < this.maxConcurrent && this.queue.length > 0) {
99
+ const job = this.queue.shift();
100
+ if (!job || job.cancelled) continue;
101
+ this.running += 1;
102
+ void this.run(job).finally(() => {
103
+ this.running -= 1;
104
+ this.pump();
105
+ });
106
+ }
107
+ }
108
+
109
+ async run(job) {
110
+ try {
111
+ job.state = 'scanning';
112
+ markUpdated(job);
113
+ const scan = job.files
114
+ ? { files: job.files, totalBytes: job.files.reduce((sum, file) => sum + Number(file.size || 0), 0) }
115
+ : await this.filesystem.scan(job.itemIds);
116
+ if (job.cancelled) return;
117
+ job.totalFiles = scan.files.length;
118
+ job.totalBytes = scan.totalBytes;
119
+ job.state = 'sending';
120
+ markUpdated(job);
121
+ if (scan.files.length === 0) {
122
+ job.state = 'completed';
123
+ job.completedAt = new Date().toISOString();
124
+ markUpdated(job);
125
+ await this.finishCallback(job, true);
126
+ return;
127
+ }
128
+
129
+ for (const file of scan.files) {
130
+ if (job.cancelled) return;
131
+ job.currentPath = file.relativePath;
132
+ markUpdated(job);
133
+ await this.sendFile(job, file);
134
+ job.completedFiles += 1;
135
+ markUpdated(job);
136
+ }
137
+
138
+ if (job.cancelled) return;
139
+ job.state = job.failedTargets.size === 0 ? 'completed' : 'partially-failed';
140
+ job.completedAt = new Date().toISOString();
141
+ job.currentPath = '';
142
+ if (job.failedTargets.size > 0) job.error = 'one-or-more-targets-failed';
143
+ markUpdated(job);
144
+ await this.finishCallback(job, job.state === 'completed');
145
+ } catch (error) {
146
+ if (job.cancelled) return;
147
+ job.state = job.failedTargets.size > 0 ? 'partially-failed' : 'failed';
148
+ job.error = error instanceof Error ? error.message : String(error);
149
+ job.completedAt = new Date().toISOString();
150
+ job.currentPath = '';
151
+ markUpdated(job);
152
+ await this.finishCallback(job, false);
153
+ }
154
+ }
155
+
156
+ async sendFile(job, file) {
157
+ const activeTargets = () => job.deviceIds.filter(deviceId => !job.failedTargets.has(deviceId));
158
+ if (Number(file.size || 0) === 0) {
159
+ await this.sendChunk(job, file, 0, Buffer.alloc(0), true, activeTargets());
160
+ return;
161
+ }
162
+ const stream = fs.createReadStream(file.absolutePath, { highWaterMark: CHUNK_BYTES });
163
+ job.currentStream = stream;
164
+ let offset = 0;
165
+ try {
166
+ for await (const chunk of stream) {
167
+ if (job.cancelled) return;
168
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
169
+ const final = offset + buffer.length >= Number(file.size || 0);
170
+ const targets = activeTargets();
171
+ if (targets.length === 0) throw new Error('all-targets-failed');
172
+ await this.sendChunk(job, file, offset, buffer, final, targets);
173
+ offset += buffer.length;
174
+ job.sentBytes += buffer.length;
175
+ markUpdated(job);
176
+ await yieldToEventLoop();
177
+ }
178
+ } finally {
179
+ job.currentStream = null;
180
+ }
181
+ }
182
+
183
+ async sendChunk(job, file, offset, buffer, final, targets) {
184
+ for (const deviceId of targets) {
185
+ if (job.cancelled) return;
186
+ const result = this.remoteHub.sendCommand(deviceId, {
187
+ command: 'file.transfer.chunk',
188
+ payload: {
189
+ transferId: job.jobId,
190
+ remoteDirectory: job.remoteDirectory,
191
+ name: file.name,
192
+ relativePath: file.relativePath,
193
+ offset,
194
+ totalBytes: Number(file.size || 0),
195
+ final,
196
+ dataBase64: buffer.toString('base64'),
197
+ lastModified: Number(file.modifiedMs || 0) || 0,
198
+ requestedAt: new Date().toISOString()
199
+ }
200
+ });
201
+ if (!result?.ok) {
202
+ job.failedTargets.add(deviceId);
203
+ job.targetErrors.set(deviceId, String(result?.error || 'device-not-connected'));
204
+ }
205
+ }
206
+ }
207
+
208
+ async finishCallback(job, completed) {
209
+ if (typeof job.onComplete !== 'function') return;
210
+ try {
211
+ await job.onComplete({ completed, job: snapshot(job) });
212
+ } catch (error) {
213
+ job.error = error instanceof Error ? error.message : String(error);
214
+ markUpdated(job);
215
+ }
216
+ }
217
+
218
+ prune() {
219
+ const threshold = Date.now() - JOB_RETENTION_MS;
220
+ for (const [id, job] of this.jobs) {
221
+ const createdAt = Date.parse(job.createdAt);
222
+ if (job.completedAt && Number.isFinite(createdAt) && createdAt < threshold) this.jobs.delete(id);
223
+ }
224
+ }
225
+ }
226
+
227
+ export function createHubTransferJobs(options) {
228
+ return new HubTransferJobs(options);
229
+ }
package/src/lzo1x.js ADDED
@@ -0,0 +1,109 @@
1
+ function requireInput(input, index, length) {
2
+ if (length < 0 || index < 0 || index + length > input.length) {
3
+ throw new Error('Invalid LZO1X block: input overrun.');
4
+ }
5
+ }
6
+
7
+ function readExtendedLength(input, state, basis) {
8
+ let zeroBytes = 0;
9
+ while (true) {
10
+ requireInput(input, state.inputIndex, 1);
11
+ if (input[state.inputIndex] !== 0) break;
12
+ zeroBytes += 1;
13
+ state.inputIndex += 1;
14
+ }
15
+ const length = zeroBytes * 255 + basis + input[state.inputIndex];
16
+ state.inputIndex += 1;
17
+ return length;
18
+ }
19
+
20
+ function copyLiteral(input, state, output, length) {
21
+ requireInput(input, state.inputIndex, length);
22
+ if (state.outputIndex + length > output.length) {
23
+ throw new Error('Invalid LZO1X block: output overrun.');
24
+ }
25
+ output.set(input.subarray(state.inputIndex, state.inputIndex + length), state.outputIndex);
26
+ state.inputIndex += length;
27
+ state.outputIndex += length;
28
+ }
29
+
30
+ export function decodeLzo1xBlock(input, outputLength) {
31
+ if (!(input instanceof Uint8Array) || input.length < 3 || outputLength < 0) {
32
+ throw new Error('Invalid LZO1X block.');
33
+ }
34
+ const output = new Uint8Array(outputLength);
35
+ const cursor = { inputIndex: 0, outputIndex: 0 };
36
+ let state = 0;
37
+ let matchLength = 0;
38
+ if (input[cursor.inputIndex] >= 22) {
39
+ const length = input[cursor.inputIndex] - 17;
40
+ cursor.inputIndex += 1;
41
+ copyLiteral(input, cursor, output, length);
42
+ state = 4;
43
+ } else if (input[cursor.inputIndex] >= 18) {
44
+ state = input[cursor.inputIndex] - 17;
45
+ cursor.inputIndex += 1;
46
+ copyLiteral(input, cursor, output, state);
47
+ }
48
+ while (true) {
49
+ requireInput(input, cursor.inputIndex, 1);
50
+ const instruction = input[cursor.inputIndex++];
51
+ let nextState;
52
+ let matchIndex;
53
+ if ((instruction & 0xc0) !== 0) {
54
+ requireInput(input, cursor.inputIndex, 1);
55
+ matchIndex = cursor.outputIndex - ((input[cursor.inputIndex] << 3) + ((instruction >> 2) & 7) + 1);
56
+ cursor.inputIndex += 1;
57
+ matchLength = (instruction >> 5) + 1;
58
+ nextState = instruction & 3;
59
+ } else if ((instruction & 0x20) !== 0) {
60
+ matchLength = (instruction & 0x1f) + 2;
61
+ if (matchLength === 2) matchLength += readExtendedLength(input, cursor, 31);
62
+ requireInput(input, cursor.inputIndex, 2);
63
+ const encoded = input[cursor.inputIndex] | (input[cursor.inputIndex + 1] << 8);
64
+ cursor.inputIndex += 2;
65
+ matchIndex = cursor.outputIndex - ((encoded >> 2) + 1);
66
+ nextState = encoded & 3;
67
+ } else if ((instruction & 0x10) !== 0) {
68
+ matchLength = (instruction & 7) + 2;
69
+ if (matchLength === 2) matchLength += readExtendedLength(input, cursor, 7);
70
+ requireInput(input, cursor.inputIndex, 2);
71
+ const encoded = input[cursor.inputIndex] | (input[cursor.inputIndex + 1] << 8);
72
+ cursor.inputIndex += 2;
73
+ matchIndex = cursor.outputIndex - (((instruction & 8) << 11) + (encoded >> 2));
74
+ nextState = encoded & 3;
75
+ if (matchIndex === cursor.outputIndex) break;
76
+ matchIndex -= 0x4000;
77
+ } else if (state === 0) {
78
+ let length = instruction + 3;
79
+ if (length === 3) length += readExtendedLength(input, cursor, 15);
80
+ copyLiteral(input, cursor, output, length);
81
+ state = 4;
82
+ continue;
83
+ } else if (state !== 4) {
84
+ requireInput(input, cursor.inputIndex, 1);
85
+ nextState = instruction & 3;
86
+ matchIndex = cursor.outputIndex - ((instruction >> 2) + (input[cursor.inputIndex] << 2) + 1);
87
+ cursor.inputIndex += 1;
88
+ matchLength = 2;
89
+ } else {
90
+ requireInput(input, cursor.inputIndex, 1);
91
+ nextState = instruction & 3;
92
+ matchIndex = cursor.outputIndex - ((instruction >> 2) + (input[cursor.inputIndex] << 2) + 2049);
93
+ cursor.inputIndex += 1;
94
+ matchLength = 3;
95
+ }
96
+ if (matchIndex < 0 || cursor.outputIndex + matchLength + nextState > output.length) {
97
+ throw new Error('Invalid LZO1X block: lookbehind overrun.');
98
+ }
99
+ for (let index = 0; index < matchLength; index += 1) {
100
+ output[cursor.outputIndex++] = output[matchIndex++];
101
+ }
102
+ state = nextState;
103
+ copyLiteral(input, cursor, output, nextState);
104
+ }
105
+ if (matchLength !== 3 || cursor.inputIndex !== input.length || cursor.outputIndex !== output.length) {
106
+ throw new Error('Invalid LZO1X block: incomplete output.');
107
+ }
108
+ return output;
109
+ }