nodio-cli 1.0.9 → 1.0.10
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.
- package/package.json +1 -1
- package/src/node/index.js +8 -2
- package/src/node/runtime.js +42 -6
- package/src/server/config.js +1 -0
- package/src/server/routes.js +53 -20
- package/src/user/commands.js +61 -60
- package/src/user/index.js +4 -2
package/package.json
CHANGED
package/src/node/index.js
CHANGED
|
@@ -80,7 +80,8 @@ program
|
|
|
80
80
|
.option('--capacity-gb <gb>', 'donated capacity in GB', '10')
|
|
81
81
|
.option('--auto-port-start <port>', 'start of auto-port range', '5001')
|
|
82
82
|
.option('--auto-port-end <port>', 'end of auto-port range', '5999')
|
|
83
|
-
.option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000')
|
|
83
|
+
.option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000')
|
|
84
|
+
.option('--relay-poll-ms <ms>', 'relay task poll interval in milliseconds', '1000');
|
|
84
85
|
|
|
85
86
|
program.action(async (capacityArg, options) => {
|
|
86
87
|
const autoPortStart = Number(options.autoPortStart);
|
|
@@ -92,6 +93,7 @@ program.action(async (capacityArg, options) => {
|
|
|
92
93
|
const parsedCapacityArg = parseCapacityGb(capacityArg);
|
|
93
94
|
const capacityGb = parsedCapacityArg ?? Number(options.capacityGb);
|
|
94
95
|
const heartbeatMs = Number(options.heartbeatMs);
|
|
96
|
+
const relayPollMs = Number(options.relayPollMs);
|
|
95
97
|
const advertisedHost = options.host === 'auto' ? detectAdvertisedHost() : options.host;
|
|
96
98
|
const storageDir = options.storageDir
|
|
97
99
|
? path.resolve(options.storageDir)
|
|
@@ -109,6 +111,9 @@ program.action(async (capacityArg, options) => {
|
|
|
109
111
|
if (!Number.isFinite(heartbeatMs) || heartbeatMs <= 0) {
|
|
110
112
|
throw new Error('heartbeat-ms must be greater than 0');
|
|
111
113
|
}
|
|
114
|
+
if (!Number.isFinite(relayPollMs) || relayPollMs <= 0) {
|
|
115
|
+
throw new Error('relay-poll-ms must be greater than 0');
|
|
116
|
+
}
|
|
112
117
|
|
|
113
118
|
if (isLoopbackHost(advertisedHost)) {
|
|
114
119
|
console.warn('[nodio-node] warning: loopback host is advertised; only this machine can reach this donor');
|
|
@@ -121,7 +126,8 @@ program.action(async (capacityArg, options) => {
|
|
|
121
126
|
port,
|
|
122
127
|
storageDir,
|
|
123
128
|
capacityBytes: Math.floor(capacityGb * 1024 * 1024 * 1024),
|
|
124
|
-
heartbeatIntervalMs: heartbeatMs
|
|
129
|
+
heartbeatIntervalMs: heartbeatMs,
|
|
130
|
+
relayPollIntervalMs: relayPollMs
|
|
125
131
|
});
|
|
126
132
|
|
|
127
133
|
await runtime.start();
|
package/src/node/runtime.js
CHANGED
|
@@ -15,8 +15,11 @@ class NodioNodeRuntime {
|
|
|
15
15
|
this.port = Number(options.port);
|
|
16
16
|
this.capacityBytes = Number(options.capacityBytes);
|
|
17
17
|
this.heartbeatIntervalMs = Number(options.heartbeatIntervalMs || 30000);
|
|
18
|
+
this.relayPollIntervalMs = Number(options.relayPollIntervalMs || 1000);
|
|
18
19
|
this.shardStore = new LocalShardStore(options.storageDir);
|
|
19
20
|
this.heartbeatTimer = null;
|
|
21
|
+
this.relayPollTimer = null;
|
|
22
|
+
this.relayPullInFlight = false;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
async start() {
|
|
@@ -37,6 +40,14 @@ class NodioNodeRuntime {
|
|
|
37
40
|
console.error('[heartbeat]', error.message);
|
|
38
41
|
}
|
|
39
42
|
}, this.heartbeatIntervalMs);
|
|
43
|
+
|
|
44
|
+
this.relayPollTimer = setInterval(async () => {
|
|
45
|
+
try {
|
|
46
|
+
await this.pollRelayTasks();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('[relay-pull]', error.message);
|
|
49
|
+
}
|
|
50
|
+
}, this.relayPollIntervalMs);
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
async startShardServer() {
|
|
@@ -128,6 +139,11 @@ class NodioNodeRuntime {
|
|
|
128
139
|
this.heartbeatIntervalMs = interval;
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
const relayInterval = Number(response.data.relayPollIntervalMs);
|
|
143
|
+
if (Number.isFinite(relayInterval) && relayInterval > 0) {
|
|
144
|
+
this.relayPollIntervalMs = relayInterval;
|
|
145
|
+
}
|
|
146
|
+
|
|
131
147
|
console.log(
|
|
132
148
|
`Registered node ${this.nodeId} | min replicas: ${response.data.minReplicas} | emergency floor: ${response.data.emergencyReplicaFloor}`
|
|
133
149
|
);
|
|
@@ -146,16 +162,36 @@ class NodioNodeRuntime {
|
|
|
146
162
|
await this.executeReplicationTask(task);
|
|
147
163
|
}
|
|
148
164
|
|
|
149
|
-
const relayTasks = response.data.relayTasks || [];
|
|
150
|
-
for (const task of relayTasks) {
|
|
151
|
-
await this.executeRelayTask(task);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
165
|
console.log(
|
|
155
|
-
`[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length}
|
|
166
|
+
`[heartbeat] ${new Date().toISOString()} | shards=${shardIds.length} | tasks=${tasks.length}`
|
|
156
167
|
);
|
|
157
168
|
}
|
|
158
169
|
|
|
170
|
+
async pollRelayTasks() {
|
|
171
|
+
if (this.relayPullInFlight) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.relayPullInFlight = true;
|
|
176
|
+
try {
|
|
177
|
+
const response = await axios.post(`${this.serverUrl}/api/nodes/relay-pull`, {
|
|
178
|
+
nodeId: this.nodeId
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const relayTasks = response.data.relayTasks || [];
|
|
182
|
+
for (const task of relayTasks) {
|
|
183
|
+
// eslint-disable-next-line no-await-in-loop
|
|
184
|
+
await this.executeRelayTask(task);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (relayTasks.length > 0) {
|
|
188
|
+
console.log(`[relay-pull] ${new Date().toISOString()} | relayTasks=${relayTasks.length}`);
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
this.relayPullInFlight = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
159
195
|
async executeReplicationTask(task) {
|
|
160
196
|
const sourceShardUrl = `${normalizeUrl(task.sourceUrl)}/shards/${task.shardId}`;
|
|
161
197
|
try {
|
package/src/server/config.js
CHANGED
|
@@ -6,6 +6,7 @@ function getServerConfig() {
|
|
|
6
6
|
port: Number(process.env.PORT || process.env.NODIO_SERVER_PORT || 4000),
|
|
7
7
|
mongoUri: process.env.NODIO_MONGO_URI || 'mongodb://127.0.0.1:27017/nodio',
|
|
8
8
|
heartbeatIntervalMs: Number(process.env.NODIO_HEARTBEAT_INTERVAL_MS || 10000),
|
|
9
|
+
relayPollIntervalMs: Number(process.env.NODIO_RELAY_POLL_INTERVAL_MS || 1000),
|
|
9
10
|
offlineAfterMisses: Number(process.env.NODIO_OFFLINE_AFTER_MISSES || 3),
|
|
10
11
|
minReplicas: Number(process.env.NODIO_MIN_REPLICAS || 5),
|
|
11
12
|
emergencyReplicaFloor: Number(process.env.NODIO_EMERGENCY_REPLICA_FLOOR || 2)
|
package/src/server/routes.js
CHANGED
|
@@ -23,6 +23,31 @@ function normalizeUrl(url) {
|
|
|
23
23
|
return String(url || '').replace(/\/+$/, '');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
async function claimPendingRelayTasks(nodeId, limit = 10) {
|
|
27
|
+
const pendingRelayTasks = await RelayTaskModel.find({
|
|
28
|
+
nodeId,
|
|
29
|
+
status: 'pending'
|
|
30
|
+
})
|
|
31
|
+
.sort({ createdAt: 1 })
|
|
32
|
+
.limit(limit)
|
|
33
|
+
.lean();
|
|
34
|
+
|
|
35
|
+
if (pendingRelayTasks.length > 0) {
|
|
36
|
+
await RelayTaskModel.updateMany(
|
|
37
|
+
{ _id: { $in: pendingRelayTasks.map((task) => task._id) } },
|
|
38
|
+
{ $set: { status: 'in_progress' }, $inc: { attempts: 1 } }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return pendingRelayTasks.map((task) => ({
|
|
43
|
+
taskId: task._id.toString(),
|
|
44
|
+
taskType: task.taskType,
|
|
45
|
+
shardId: task.shardId,
|
|
46
|
+
fileId: task.fileId,
|
|
47
|
+
dataBase64: task.dataBase64
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
function buildRoutes(config) {
|
|
27
52
|
const router = express.Router();
|
|
28
53
|
|
|
@@ -135,6 +160,7 @@ function buildRoutes(config) {
|
|
|
135
160
|
nodeId: node.nodeId,
|
|
136
161
|
status: node.status,
|
|
137
162
|
heartbeatIntervalMs: config.heartbeatIntervalMs,
|
|
163
|
+
relayPollIntervalMs: config.relayPollIntervalMs,
|
|
138
164
|
minReplicas: config.minReplicas,
|
|
139
165
|
emergencyReplicaFloor: config.emergencyReplicaFloor
|
|
140
166
|
});
|
|
@@ -237,33 +263,40 @@ function buildRoutes(config) {
|
|
|
237
263
|
});
|
|
238
264
|
}
|
|
239
265
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
266
|
+
res.json({
|
|
267
|
+
ok: true,
|
|
268
|
+
now: new Date().toISOString(),
|
|
269
|
+
replicationTasks: tasksWithSourceUrl,
|
|
270
|
+
relayTasks: []
|
|
271
|
+
});
|
|
272
|
+
} catch (error) {
|
|
273
|
+
next(error);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
247
276
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
);
|
|
277
|
+
router.post('/nodes/relay-pull', async (req, res, next) => {
|
|
278
|
+
try {
|
|
279
|
+
const { nodeId } = req.body;
|
|
280
|
+
if (!nodeId) {
|
|
281
|
+
return res.status(400).json({ error: 'nodeId is required' });
|
|
253
282
|
}
|
|
254
283
|
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
284
|
+
const node = await NodeModel.findOne({ nodeId });
|
|
285
|
+
if (!node) {
|
|
286
|
+
return res.status(404).json({ error: 'node not found' });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const relayTasks = await claimPendingRelayTasks(nodeId, 10);
|
|
290
|
+
|
|
291
|
+
if (relayTasks.length > 0 && node.pendingRelayAlert) {
|
|
292
|
+
node.pendingRelayAlert = false;
|
|
293
|
+
node.pendingRelayAlertAt = null;
|
|
294
|
+
await node.save();
|
|
295
|
+
}
|
|
262
296
|
|
|
263
297
|
res.json({
|
|
264
298
|
ok: true,
|
|
265
299
|
now: new Date().toISOString(),
|
|
266
|
-
replicationTasks: tasksWithSourceUrl,
|
|
267
300
|
relayTasks
|
|
268
301
|
});
|
|
269
302
|
} catch (error) {
|
package/src/user/commands.js
CHANGED
|
@@ -31,7 +31,7 @@ function sleep(ms) {
|
|
|
31
31
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs =
|
|
34
|
+
async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, timeoutMs = 45000, pollMs = 200 }) {
|
|
35
35
|
const opId = uuidv4();
|
|
36
36
|
await api.post('/relay/shards/store', {
|
|
37
37
|
opId,
|
|
@@ -42,14 +42,7 @@ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, time
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
// Alert server that relay tasks are pending so donors check urgently
|
|
45
|
-
|
|
46
|
-
for (const nodeId of nodeIds) {
|
|
47
|
-
// eslint-disable-next-line no-await-in-loop
|
|
48
|
-
await api.post(`/nodes/${nodeId}/alert-relay-pending`);
|
|
49
|
-
}
|
|
50
|
-
} catch (alertError) {
|
|
51
|
-
console.warn('relay alert failed:', alertError.message);
|
|
52
|
-
}
|
|
45
|
+
await Promise.allSettled(nodeIds.map((nodeId) => api.post(`/nodes/${nodeId}/alert-relay-pending`)));
|
|
53
46
|
|
|
54
47
|
const deadline = Date.now() + timeoutMs;
|
|
55
48
|
while (Date.now() < deadline) {
|
|
@@ -69,7 +62,7 @@ async function relayStoreShard(api, { shardId, fileId, nodeIds, dataBuffer, time
|
|
|
69
62
|
throw new Error(`relay store timed out for shard ${shardId}`);
|
|
70
63
|
}
|
|
71
64
|
|
|
72
|
-
async function relayFetchShard(api, { shardId, nodeIds, timeoutMs =
|
|
65
|
+
async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 45000, pollMs = 200 }) {
|
|
73
66
|
const opId = uuidv4();
|
|
74
67
|
await api.post('/relay/shards/fetch', {
|
|
75
68
|
opId,
|
|
@@ -78,14 +71,7 @@ async function relayFetchShard(api, { shardId, nodeIds, timeoutMs = 90000, pollM
|
|
|
78
71
|
});
|
|
79
72
|
|
|
80
73
|
// Alert server that relay tasks are pending so donors check urgently
|
|
81
|
-
|
|
82
|
-
for (const nodeId of nodeIds) {
|
|
83
|
-
// eslint-disable-next-line no-await-in-loop
|
|
84
|
-
await api.post(`/nodes/${nodeId}/alert-relay-pending`);
|
|
85
|
-
}
|
|
86
|
-
} catch (alertError) {
|
|
87
|
-
console.warn('relay alert failed:', alertError.message);
|
|
88
|
-
}
|
|
74
|
+
await Promise.allSettled(nodeIds.map((nodeId) => api.post(`/nodes/${nodeId}/alert-relay-pending`)));
|
|
89
75
|
|
|
90
76
|
const deadline = Date.now() + timeoutMs;
|
|
91
77
|
while (Date.now() < deadline) {
|
|
@@ -120,7 +106,8 @@ async function uploadFile(options) {
|
|
|
120
106
|
const serverUrl = options.server;
|
|
121
107
|
const shardSizeMb = Number(options.shardSizeMb || 1);
|
|
122
108
|
const replicas = Number(options.replicas || 5);
|
|
123
|
-
const directTimeoutMs = Number(options.directTimeoutMs ||
|
|
109
|
+
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
110
|
+
const relayFirst = Boolean(options.relayFirst);
|
|
124
111
|
|
|
125
112
|
if (!Number.isFinite(shardSizeMb) || shardSizeMb <= 0) {
|
|
126
113
|
throw new Error('shard-size-mb must be greater than 0');
|
|
@@ -177,29 +164,40 @@ async function uploadFile(options) {
|
|
|
177
164
|
throw new Error(`placement failed for ${shardId}: expected ${replicas} replicas`);
|
|
178
165
|
}
|
|
179
166
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
})
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
const successfulReplicas = directWriteResults
|
|
192
|
-
.filter((result) => result.status === 'fulfilled')
|
|
193
|
-
.map((result) => result.value);
|
|
194
|
-
|
|
195
|
-
const failedReplicas = directWriteResults
|
|
196
|
-
.map((result, index) => ({ result, replica: plannedReplicas[index] }))
|
|
197
|
-
.filter((entry) => entry.result.status === 'rejected')
|
|
198
|
-
.map((entry) => ({
|
|
199
|
-
nodeId: entry.replica.nodeId,
|
|
200
|
-
url: entry.replica.url,
|
|
201
|
-
error: entry.result.reason?.message || 'direct write failed'
|
|
167
|
+
let successfulReplicas = [];
|
|
168
|
+
let failedReplicas = [];
|
|
169
|
+
|
|
170
|
+
if (relayFirst) {
|
|
171
|
+
failedReplicas = plannedReplicas.map((replica) => ({
|
|
172
|
+
nodeId: replica.nodeId,
|
|
173
|
+
url: replica.url,
|
|
174
|
+
error: 'direct write skipped (relay-first)'
|
|
202
175
|
}));
|
|
176
|
+
} else {
|
|
177
|
+
const directWriteResults = await Promise.allSettled(
|
|
178
|
+
plannedReplicas.map(async (replica) => {
|
|
179
|
+
const putUrl = `${normalizeUrl(replica.url)}/shards/${shardId}`;
|
|
180
|
+
await axios.put(putUrl, encrypted.cipherText, {
|
|
181
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
182
|
+
timeout: directTimeoutMs
|
|
183
|
+
});
|
|
184
|
+
return replica;
|
|
185
|
+
})
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
successfulReplicas = directWriteResults
|
|
189
|
+
.filter((result) => result.status === 'fulfilled')
|
|
190
|
+
.map((result) => result.value);
|
|
191
|
+
|
|
192
|
+
failedReplicas = directWriteResults
|
|
193
|
+
.map((result, index) => ({ result, replica: plannedReplicas[index] }))
|
|
194
|
+
.filter((entry) => entry.result.status === 'rejected')
|
|
195
|
+
.map((entry) => ({
|
|
196
|
+
nodeId: entry.replica.nodeId,
|
|
197
|
+
url: entry.replica.url,
|
|
198
|
+
error: entry.result.reason?.message || 'direct write failed'
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
203
201
|
|
|
204
202
|
if (failedReplicas.length > 0) {
|
|
205
203
|
try {
|
|
@@ -272,7 +270,8 @@ async function downloadFile(options) {
|
|
|
272
270
|
const serverUrl = options.server;
|
|
273
271
|
const output = options.output ? path.resolve(options.output) : path.resolve(`./${fileId}.downloaded`);
|
|
274
272
|
const keyBuffer = parseAesKey(options.keyBase64);
|
|
275
|
-
const directTimeoutMs = Number(options.directTimeoutMs ||
|
|
273
|
+
const directTimeoutMs = Number(options.directTimeoutMs || 1200);
|
|
274
|
+
const relayFirst = Boolean(options.relayFirst);
|
|
276
275
|
|
|
277
276
|
if (!Number.isFinite(directTimeoutMs) || directTimeoutMs <= 0) {
|
|
278
277
|
throw new Error('direct-timeout-ms must be greater than 0');
|
|
@@ -300,24 +299,26 @@ async function downloadFile(options) {
|
|
|
300
299
|
}
|
|
301
300
|
|
|
302
301
|
let encryptedBuffer = null;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
302
|
+
if (!relayFirst) {
|
|
303
|
+
const directFetchResults = await Promise.allSettled(
|
|
304
|
+
(shard.replicas || []).map(async (replica) => {
|
|
305
|
+
const getUrl = `${normalizeUrl(replica.url)}/shards/${shard.shardId}`;
|
|
306
|
+
const response = await axios.get(getUrl, {
|
|
307
|
+
responseType: 'arraybuffer',
|
|
308
|
+
timeout: directTimeoutMs
|
|
309
|
+
});
|
|
310
|
+
const candidate = Buffer.from(response.data);
|
|
311
|
+
if (sha256Hex(candidate) !== shard.checksum) {
|
|
312
|
+
throw new Error('checksum mismatch');
|
|
313
|
+
}
|
|
314
|
+
return candidate;
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const successfulDirectFetch = directFetchResults.find((result) => result.status === 'fulfilled');
|
|
319
|
+
if (successfulDirectFetch) {
|
|
320
|
+
encryptedBuffer = successfulDirectFetch.value;
|
|
321
|
+
}
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
if (!encryptedBuffer && Array.isArray(shard.replicas) && shard.replicas.length > 0) {
|
package/src/user/index.js
CHANGED
|
@@ -14,7 +14,8 @@ program
|
|
|
14
14
|
.option('--file-id <id>', 'custom file ID (optional)')
|
|
15
15
|
.option('--shard-size-mb <mb>', 'plaintext shard size in MB', '1')
|
|
16
16
|
.option('--replicas <count>', 'replicas per shard (minimum 5)', '5')
|
|
17
|
-
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '
|
|
17
|
+
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
|
|
18
|
+
.option('--relay-first', 'skip direct donor attempts and use relay path immediately')
|
|
18
19
|
.option('--key-base64 <key>', '32-byte AES key in base64 (optional)')
|
|
19
20
|
.action(async (options) => {
|
|
20
21
|
await uploadFile(options);
|
|
@@ -27,7 +28,8 @@ program
|
|
|
27
28
|
.requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
|
|
28
29
|
.option('--server <url>', 'central server URL', 'https://api.nodio.me')
|
|
29
30
|
.option('--output <path>', 'output file path')
|
|
30
|
-
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '
|
|
31
|
+
.option('--direct-timeout-ms <ms>', 'timeout for each direct donor attempt before relay fallback', '1200')
|
|
32
|
+
.option('--relay-first', 'skip direct donor attempts and use relay path immediately')
|
|
31
33
|
.action(async (options) => {
|
|
32
34
|
await downloadFile(options);
|
|
33
35
|
});
|