openrtc-file-transfer 2.9.7 → 2.9.8
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/README.md +118 -0
- package/dist/capabilities.d.ts +25 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +58 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/flowControl.d.ts +45 -0
- package/dist/flowControl.d.ts.map +1 -0
- package/dist/flowControl.js +192 -0
- package/dist/flowControl.js.map +1 -0
- package/dist/index.d.ts +93 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +599 -145
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import { requiredTransferCapabilities, withRequiredCapabilities, requiredCapabilities, transferCapabilities, requireCapabilities, MAX_CAPABILITY_BYTES, LEGACY_MAX_FILE_BYTES } from './capabilities.js';
|
|
2
|
+
export { requiredTransferCapabilities, withRequiredCapabilities, REQUIRED_CAPABILITIES_METADATA_KEY, LEGACY_MAX_FILE_BYTES } from './capabilities.js';
|
|
3
|
+
import { sha256 as incrementalSha256 } from '@noble/hashes/sha256';
|
|
4
|
+
/** Checkpoint-before-offset and product-commit-before-completion semantics. */
|
|
5
|
+
export const DURABLE_DELIVERY_VERSION = 1;
|
|
6
|
+
/** Per-sink body policy, legacy admission and execution cancellation. */
|
|
7
|
+
export const BULK_RECEIVE_FLOW_CONTROL_VERSION = 1;
|
|
8
|
+
export const BULK_RECEIVE_ADMISSION_WAIT_VERSION = 1;
|
|
9
|
+
/** Unpaced stream/v1 sink policy and idle execution retirement. */
|
|
10
|
+
export const BULK_STREAM_RECEIVE_FLOW_CONTROL_VERSION = 1;
|
|
1
11
|
/**
|
|
2
12
|
* Transitional adapter for advanced native hosts that obtain a low-level
|
|
3
13
|
* runtime through `runtimeFromCapability()`. The file-transfer package owns
|
|
@@ -108,8 +118,24 @@ const ROUTED_KIND_ACK = 0x04;
|
|
|
108
118
|
const ROUTED_KIND_COMPLETE = 0x05;
|
|
109
119
|
const ROUTED_KIND_COMPLETED = 0x06;
|
|
110
120
|
const ROUTED_KIND_REJECT = 0x07;
|
|
121
|
+
const ROUTED_KIND_WAITING = 0x08;
|
|
122
|
+
const ROUTED_KIND_CAPABILITIES_REQUEST = 0x09;
|
|
123
|
+
const ROUTED_KIND_CAPABILITIES = 0x0a;
|
|
124
|
+
export const RECEIVER_WAIT_METADATA_KEY = 'openrtc.receive-wait';
|
|
125
|
+
export const MAX_RECEIVER_WAIT_LEASE_MS = 60000;
|
|
126
|
+
const DEFAULT_RECEIVER_WAIT_LEASE_MS = 10000;
|
|
127
|
+
/** Persist this capability as part of the immutable offer before acceptance. */
|
|
128
|
+
export function withReceiverWaitCapability(header) {
|
|
129
|
+
return { ...header, metadata: { ...header.metadata, [RECEIVER_WAIT_METADATA_KEY]: '1' } };
|
|
130
|
+
}
|
|
131
|
+
function supportsReceiverWait(header) {
|
|
132
|
+
return header.metadata?.[RECEIVER_WAIT_METADATA_KEY] === '1';
|
|
133
|
+
}
|
|
111
134
|
const DEFAULT_ROUTED_CHUNK_BYTES = 48 * 1024;
|
|
112
135
|
const DEFAULT_ROUTED_WINDOW_CHUNKS = 8;
|
|
136
|
+
export const MAX_TRANSFER_CHUNK_BYTES = 64 * 1024;
|
|
137
|
+
export const MAX_TRANSFER_WINDOW_CHUNKS = 64;
|
|
138
|
+
const MAX_PROTOCOL_RECOVERY_ATTEMPTS = 32;
|
|
113
139
|
const MAX_ROUTED_TRANSFER_ID_BYTES = 512;
|
|
114
140
|
function routedDebug(message, data) {
|
|
115
141
|
let enabled = globalThis.__OPENRTC_DEBUG__ === true;
|
|
@@ -121,11 +147,23 @@ function routedDebug(message, data) {
|
|
|
121
147
|
console.info(`[OpenRTC][file-transfer] ${message}`, data);
|
|
122
148
|
}
|
|
123
149
|
}
|
|
150
|
+
export { BulkFlowController } from './flowControl.js';
|
|
151
|
+
export const BULK_SEND_FLOW_CONTROL_VERSION = 1;
|
|
152
|
+
export const BULK_JOB_FLOW_CONTROL_VERSION = 1;
|
|
153
|
+
export const BULK_SCOPED_FLOW_CONTROL_VERSION = 1;
|
|
124
154
|
function routedTransferId(frame) {
|
|
125
155
|
return frame.kind === 'offer' ? frame.header.transferId : frame.transferId;
|
|
126
156
|
}
|
|
127
157
|
class TransferRejectedError extends Error {
|
|
128
158
|
}
|
|
159
|
+
// A product policy wait/rejection is not an ambiguous route send. Preserve its
|
|
160
|
+
// original typed error and leave the next application retry to the queue owner.
|
|
161
|
+
class TransferBodyAdmissionFailure extends Error {
|
|
162
|
+
constructor(failure) {
|
|
163
|
+
super(failure instanceof Error ? failure.message : String(failure));
|
|
164
|
+
this.failure = failure;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
129
167
|
function writeU64(view, offset, value) {
|
|
130
168
|
if (!Number.isSafeInteger(value) || value < 0)
|
|
131
169
|
throw new Error('routed transfer offset is invalid');
|
|
@@ -146,6 +184,21 @@ export function encodeFrame(frame, maxHeaderBytes = DEFAULT_MAX_HEADER_BYTES, ma
|
|
|
146
184
|
let kind;
|
|
147
185
|
let body;
|
|
148
186
|
switch (frame.kind) {
|
|
187
|
+
case 'capabilities-request':
|
|
188
|
+
case 'capabilities': {
|
|
189
|
+
kind = frame.kind === 'capabilities-request' ? ROUTED_KIND_CAPABILITIES_REQUEST : ROUTED_KIND_CAPABILITIES;
|
|
190
|
+
if (frame.kind === 'capabilities-request' && frame.header && frame.header.transferId !== transferId)
|
|
191
|
+
throw new Error('capability probe transferId mismatch');
|
|
192
|
+
const payload = frame.kind === 'capabilities-request'
|
|
193
|
+
? frame.header ? encodeHeader(frame.header, maxHeaderBytes).subarray(4) : new Uint8Array()
|
|
194
|
+
: new TextEncoder().encode(JSON.stringify(transferCapabilities(frame.capabilities)));
|
|
195
|
+
if (payload.byteLength > (frame.kind === 'capabilities' ? MAX_CAPABILITY_BYTES : maxHeaderBytes))
|
|
196
|
+
throw new Error('transfer capability frame is too large');
|
|
197
|
+
body = new Uint8Array(4 + payload.byteLength);
|
|
198
|
+
new DataView(body.buffer).setUint32(0, payload.byteLength, false);
|
|
199
|
+
body.set(payload, 4);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
149
202
|
case 'offer': {
|
|
150
203
|
kind = ROUTED_KIND_OFFER;
|
|
151
204
|
const header = new TextEncoder().encode(JSON.stringify(frame.header));
|
|
@@ -168,6 +221,15 @@ export function encodeFrame(frame, maxHeaderBytes = DEFAULT_MAX_HEADER_BYTES, ma
|
|
|
168
221
|
writeU64(new DataView(body.buffer), 0, frame.kind === 'resume' || frame.kind === 'ack' ? frame.offset : frame.size);
|
|
169
222
|
break;
|
|
170
223
|
}
|
|
224
|
+
case 'waiting': {
|
|
225
|
+
kind = ROUTED_KIND_WAITING;
|
|
226
|
+
if (!Number.isSafeInteger(frame.leaseMs) || frame.leaseMs < 1 || frame.leaseMs > MAX_RECEIVER_WAIT_LEASE_MS)
|
|
227
|
+
throw new Error('invalid receiver wait lease');
|
|
228
|
+
body = new Uint8Array(12);
|
|
229
|
+
writeU64(new DataView(body.buffer), 0, frame.offset);
|
|
230
|
+
new DataView(body.buffer).setUint32(8, frame.leaseMs, false);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
171
233
|
case 'chunk': {
|
|
172
234
|
kind = ROUTED_KIND_CHUNK;
|
|
173
235
|
if (!frame.bytes.byteLength || frame.bytes.byteLength > maxChunkBytes) {
|
|
@@ -210,6 +272,21 @@ export function decodeFrame(input, maxFileBytes = DEFAULT_MAX_BUFFERED_FILE_BYTE
|
|
|
210
272
|
const transferId = new TextDecoder().decode(input.slice(7, 7 + idLength));
|
|
211
273
|
const body = input.slice(7 + idLength);
|
|
212
274
|
const bodyView = new DataView(body.buffer, body.byteOffset, body.byteLength);
|
|
275
|
+
if (kind === ROUTED_KIND_CAPABILITIES_REQUEST || kind === ROUTED_KIND_CAPABILITIES) {
|
|
276
|
+
if (body.byteLength < 4)
|
|
277
|
+
throw new Error('truncated transfer capability frame');
|
|
278
|
+
const length = bodyView.getUint32(0, false);
|
|
279
|
+
if (length > (kind === ROUTED_KIND_CAPABILITIES ? MAX_CAPABILITY_BYTES : maxHeaderBytes)
|
|
280
|
+
|| body.byteLength !== 4 + length)
|
|
281
|
+
throw new Error('invalid transfer capability frame length');
|
|
282
|
+
if (kind === ROUTED_KIND_CAPABILITIES)
|
|
283
|
+
return { kind: 'capabilities', transferId,
|
|
284
|
+
capabilities: transferCapabilities(JSON.parse(new TextDecoder().decode(body.subarray(4)))) };
|
|
285
|
+
const header = length ? parseHeader(body.subarray(4), Number.MAX_SAFE_INTEGER) : undefined;
|
|
286
|
+
if (header && header.transferId !== transferId)
|
|
287
|
+
throw new Error('capability probe transferId mismatch');
|
|
288
|
+
return { kind: 'capabilities-request', transferId, header };
|
|
289
|
+
}
|
|
213
290
|
if (kind === ROUTED_KIND_OFFER) {
|
|
214
291
|
if (body.byteLength < 4)
|
|
215
292
|
throw new Error('truncated routed offer');
|
|
@@ -222,11 +299,19 @@ export function decodeFrame(input, maxFileBytes = DEFAULT_MAX_BUFFERED_FILE_BYTE
|
|
|
222
299
|
throw new Error('routed offer transferId mismatch');
|
|
223
300
|
return { kind: 'offer', header };
|
|
224
301
|
}
|
|
302
|
+
if (kind === ROUTED_KIND_WAITING) {
|
|
303
|
+
if (body.byteLength !== 12)
|
|
304
|
+
throw new Error('invalid receiver wait frame');
|
|
305
|
+
const offset = readU64(bodyView, 0), leaseMs = bodyView.getUint32(8, false);
|
|
306
|
+
if (offset > maxFileBytes || leaseMs < 1 || leaseMs > MAX_RECEIVER_WAIT_LEASE_MS)
|
|
307
|
+
throw new Error('invalid receiver wait lease or offset');
|
|
308
|
+
return { kind: 'waiting', transferId, offset, leaseMs };
|
|
309
|
+
}
|
|
225
310
|
if ([ROUTED_KIND_RESUME, ROUTED_KIND_ACK, ROUTED_KIND_COMPLETE, ROUTED_KIND_COMPLETED].includes(kind)) {
|
|
226
311
|
if (body.byteLength !== 8)
|
|
227
312
|
throw new Error('invalid routed offset frame');
|
|
228
313
|
const value = readU64(bodyView, 0);
|
|
229
|
-
if (value > maxFileBytes)
|
|
314
|
+
if (value > maxFileBytes && kind !== ROUTED_KIND_COMPLETED)
|
|
230
315
|
throw new Error('routed transfer offset exceeds configured limit');
|
|
231
316
|
if (kind === ROUTED_KIND_RESUME)
|
|
232
317
|
return { kind: 'resume', transferId, offset: value };
|
|
@@ -351,15 +436,22 @@ function parseHeader(bytes, maxFileBytes) {
|
|
|
351
436
|
if (value.sha256 !== undefined && !/^[a-f0-9]{64}$/i.test(value.sha256)) {
|
|
352
437
|
throw new Error('invalid file transfer SHA-256 digest');
|
|
353
438
|
}
|
|
439
|
+
requiredTransferCapabilities(value);
|
|
354
440
|
return value;
|
|
355
441
|
}
|
|
356
|
-
async function sha256(blob) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
442
|
+
async function sha256(blob, signal) {
|
|
443
|
+
const hash = incrementalSha256.create();
|
|
444
|
+
for (let offset = 0; offset < blob.size; offset += 64 * 1024) {
|
|
445
|
+
throwIfAborted(signal);
|
|
446
|
+
hash.update(new Uint8Array(await blob.slice(offset, offset + 64 * 1024).arrayBuffer()));
|
|
447
|
+
}
|
|
448
|
+
throwIfAborted(signal);
|
|
449
|
+
return Array.from(hash.digest(), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
361
450
|
}
|
|
362
451
|
function bufferedSink(header, remoteNodeId) {
|
|
452
|
+
if (header.size > DEFAULT_MAX_BUFFERED_FILE_BYTES) {
|
|
453
|
+
throw new Error('buffered receive limit exceeded; install a streaming receive sink');
|
|
454
|
+
}
|
|
363
455
|
const chunks = [];
|
|
364
456
|
return {
|
|
365
457
|
async write(chunk) { chunks.push(chunk.slice()); },
|
|
@@ -388,18 +480,28 @@ export function createTransfer(client, options = {}) {
|
|
|
388
480
|
&& (typeof adaptiveClient.getConnections === 'function' || !!adaptiveClient.diagnostics)) {
|
|
389
481
|
return createRoutedTransfer(client, options);
|
|
390
482
|
}
|
|
483
|
+
if (options.beforeReceiveBody)
|
|
484
|
+
throw new Error('transfer_capability_required: receiver pacing requires routed file transfer');
|
|
391
485
|
const channel = options.channel ?? CHANNEL;
|
|
392
486
|
const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
|
|
393
487
|
const maxHeaderBytes = options.maxHeaderBytes ?? DEFAULT_MAX_HEADER_BYTES;
|
|
394
488
|
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_BUFFERED_FILE_BYTES;
|
|
395
489
|
const ackTimeoutMs = options.ackTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
|
|
396
|
-
if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0)
|
|
397
|
-
throw new Error(
|
|
490
|
+
if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0 || chunkBytes > MAX_TRANSFER_CHUNK_BYTES)
|
|
491
|
+
throw new Error(`chunkBytes must be within 1..${MAX_TRANSFER_CHUNK_BYTES}`);
|
|
492
|
+
if (!Number.isSafeInteger(maxHeaderBytes) || maxHeaderBytes <= 0 || maxHeaderBytes > DEFAULT_MAX_HEADER_BYTES)
|
|
493
|
+
throw new Error(`maxHeaderBytes must be within 1..${DEFAULT_MAX_HEADER_BYTES}`);
|
|
398
494
|
if (!Number.isSafeInteger(ackTimeoutMs) || ackTimeoutMs <= 0)
|
|
399
495
|
throw new Error('ackTimeoutMs must be positive');
|
|
400
496
|
client.channels.register(channel);
|
|
401
497
|
return {
|
|
498
|
+
async sendAccepted(_deviceId, _source, _options = {}) {
|
|
499
|
+
throw new Error('transfer_capability_required: durable offer reconciliation requires routed file transfer');
|
|
500
|
+
},
|
|
402
501
|
async send(deviceId, file, sendOptions = {}) {
|
|
502
|
+
if (sendOptions.requiredCapabilities || sendOptions.metadata?.['openrtc.required-capabilities.v1']
|
|
503
|
+
|| file.size > LEGACY_MAX_FILE_BYTES)
|
|
504
|
+
throw new Error('transfer_capability_required: selected size/features require routed negotiation');
|
|
403
505
|
if (file.size > maxFileBytes)
|
|
404
506
|
throw new Error(`file exceeds ${maxFileBytes} byte transfer limit`);
|
|
405
507
|
throwIfAborted(sendOptions.signal);
|
|
@@ -432,6 +534,11 @@ export function createTransfer(client, options = {}) {
|
|
|
432
534
|
while (sent < file.size) {
|
|
433
535
|
throwIfAborted(sendOptions.signal);
|
|
434
536
|
const chunk = new Uint8Array(await file.slice(sent, sent + chunkBytes).arrayBuffer());
|
|
537
|
+
await options.beforeSendBody?.({ transferId: id, peerId: deviceId,
|
|
538
|
+
bytes: chunk.byteLength, offset: sent, signal: sendOptions.signal });
|
|
539
|
+
await sendOptions.beforeSendBody?.({ transferId: id, peerId: deviceId,
|
|
540
|
+
bytes: chunk.byteLength, offset: sent, signal: sendOptions.signal });
|
|
541
|
+
throwIfAborted(sendOptions.signal);
|
|
435
542
|
await writer.write(chunk);
|
|
436
543
|
sent += chunk.byteLength;
|
|
437
544
|
sendOptions.onProgress?.({ transferId: id, transferredBytes: sent, totalBytes: file.size, direction: 'send' });
|
|
@@ -479,15 +586,36 @@ export function createTransfer(client, options = {}) {
|
|
|
479
586
|
const reader = new ByteReader(stream.readable);
|
|
480
587
|
const writer = stream.writable.getWriter();
|
|
481
588
|
let sink;
|
|
589
|
+
const execution = new AbortController();
|
|
590
|
+
const subscriptions = [];
|
|
591
|
+
const watch = (signal) => {
|
|
592
|
+
if (!signal)
|
|
593
|
+
return;
|
|
594
|
+
const abort = () => {
|
|
595
|
+
execution.abort(signal.reason);
|
|
596
|
+
void reader.cancel(signal.reason);
|
|
597
|
+
};
|
|
598
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
599
|
+
subscriptions.push(() => signal.removeEventListener('abort', abort));
|
|
600
|
+
if (signal.aborted)
|
|
601
|
+
abort();
|
|
602
|
+
};
|
|
603
|
+
watch(receiveOptions.signal);
|
|
482
604
|
try {
|
|
483
|
-
const lengthBytes = await reader.exact(4,
|
|
605
|
+
const lengthBytes = await reader.exact(4, execution.signal);
|
|
484
606
|
const headerLength = new DataView(lengthBytes.buffer, lengthBytes.byteOffset, 4).getUint32(0, false);
|
|
485
607
|
if (!headerLength || headerLength > maxHeaderBytes)
|
|
486
608
|
throw new Error(`invalid file transfer header length ${headerLength}`);
|
|
487
|
-
const header = parseHeader(await reader.exact(headerLength,
|
|
609
|
+
const header = parseHeader(await reader.exact(headerLength, execution.signal), maxFileBytes);
|
|
610
|
+
if (requiredTransferCapabilities(header))
|
|
611
|
+
throw new Error('transfer_capability_required: stream/v1 cannot negotiate required transfer features');
|
|
488
612
|
sink = options.createReceiveSink
|
|
489
613
|
? await options.createReceiveSink(header, incoming.remoteNodeId)
|
|
490
614
|
: bufferedSink(header, incoming.remoteNodeId);
|
|
615
|
+
if (sink.bodyPolicy?.requiresReceiverWait)
|
|
616
|
+
throw new Error('transfer_capability_required: sink body policy requires routed file transfer');
|
|
617
|
+
watch(sink.bodyPolicy?.signal);
|
|
618
|
+
throwIfAborted(execution.signal);
|
|
491
619
|
const offset = header.offset ?? 0;
|
|
492
620
|
if (offset > 0 && sink.resumeOffset !== offset) {
|
|
493
621
|
throw new Error(`receive sink cannot resume at offset ${offset}`);
|
|
@@ -495,14 +623,20 @@ export function createTransfer(client, options = {}) {
|
|
|
495
623
|
let received = offset;
|
|
496
624
|
receiveOptions.onProgress?.({ transferId: header.transferId, transferredBytes: received, totalBytes: header.size, direction: 'receive' });
|
|
497
625
|
while (received < header.size) {
|
|
498
|
-
throwIfAborted(
|
|
499
|
-
const chunk = await reader.some(Math.min(chunkBytes, header.size - received),
|
|
626
|
+
throwIfAborted(execution.signal);
|
|
627
|
+
const chunk = await reader.some(Math.min(chunkBytes, header.size - received), execution.signal);
|
|
628
|
+
throwIfAborted(execution.signal);
|
|
500
629
|
if (!chunk)
|
|
501
630
|
throw new Error('file transfer stream ended before declared size');
|
|
631
|
+
await sink.bodyPolicy?.beforeAttempt({ transferId: header.transferId,
|
|
632
|
+
remoteNodeId: incoming.remoteNodeId, offset: received,
|
|
633
|
+
bytes: chunk.byteLength, signal: execution.signal });
|
|
634
|
+
throwIfAborted(execution.signal);
|
|
502
635
|
await sink.write(chunk);
|
|
503
636
|
received += chunk.byteLength;
|
|
504
637
|
receiveOptions.onProgress?.({ transferId: header.transferId, transferredBytes: received, totalBytes: header.size, direction: 'receive' });
|
|
505
638
|
}
|
|
639
|
+
await sink.checkpoint?.();
|
|
506
640
|
const result = await sink.close();
|
|
507
641
|
if (header.sha256) {
|
|
508
642
|
const verified = sink.verifySha256
|
|
@@ -513,9 +647,13 @@ export function createTransfer(client, options = {}) {
|
|
|
513
647
|
if (!verified)
|
|
514
648
|
throw new Error('file transfer integrity check failed');
|
|
515
649
|
}
|
|
650
|
+
await receiveOptions.onFile({ header, remoteNodeId: incoming.remoteNodeId, result });
|
|
651
|
+
throwIfAborted(execution.signal);
|
|
652
|
+
await sink.commit?.();
|
|
653
|
+
throwIfAborted(execution.signal);
|
|
516
654
|
await writer.write(new Uint8Array([ACK_OK]));
|
|
517
655
|
await writer.close();
|
|
518
|
-
|
|
656
|
+
writer.releaseLock();
|
|
519
657
|
}
|
|
520
658
|
catch (error) {
|
|
521
659
|
await sink?.abort(error).catch(() => undefined);
|
|
@@ -525,7 +663,13 @@ export function createTransfer(client, options = {}) {
|
|
|
525
663
|
// suppress local error reporting through backpressure.
|
|
526
664
|
void writer.write(new Uint8Array([ACK_REJECTED]))
|
|
527
665
|
.then(() => writer.close())
|
|
528
|
-
.catch(() => writer.abort(error).catch(() => undefined))
|
|
666
|
+
.catch(() => writer.abort(error).catch(() => undefined))
|
|
667
|
+
.finally(() => writer.releaseLock());
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
for (const unsubscribe of subscriptions)
|
|
671
|
+
unsubscribe();
|
|
672
|
+
await reader.cancel('file transfer stream consumed');
|
|
529
673
|
}
|
|
530
674
|
})();
|
|
531
675
|
return true;
|
|
@@ -550,20 +694,41 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
550
694
|
const windowChunks = options.windowChunks ?? DEFAULT_ROUTED_WINDOW_CHUNKS;
|
|
551
695
|
const maxRecoveryAttempts = options.maxRecoveryAttempts ?? 3;
|
|
552
696
|
const channel = options.routing === 'segmented-stream' ? (options.channel ?? CHANNEL) : null;
|
|
697
|
+
const receiveWaitLeaseMs = options.receiveWaitLeaseMs ?? DEFAULT_RECEIVER_WAIT_LEASE_MS;
|
|
698
|
+
if (!Number.isSafeInteger(receiveWaitLeaseMs) || receiveWaitLeaseMs < 1 || receiveWaitLeaseMs > MAX_RECEIVER_WAIT_LEASE_MS)
|
|
699
|
+
throw new Error('receiveWaitLeaseMs must be within 1..60000');
|
|
553
700
|
if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0 || chunkBytes > DEFAULT_ROUTED_CHUNK_BYTES) {
|
|
554
701
|
throw new Error(`routed chunkBytes must be within 1..${DEFAULT_ROUTED_CHUNK_BYTES}`);
|
|
555
702
|
}
|
|
556
|
-
if (!Number.isSafeInteger(
|
|
557
|
-
throw new Error(
|
|
558
|
-
if (!Number.isSafeInteger(
|
|
559
|
-
throw new Error(
|
|
703
|
+
if (!Number.isSafeInteger(maxHeaderBytes) || maxHeaderBytes <= 0 || maxHeaderBytes > DEFAULT_MAX_HEADER_BYTES)
|
|
704
|
+
throw new Error(`maxHeaderBytes must be within 1..${DEFAULT_MAX_HEADER_BYTES}`);
|
|
705
|
+
if (!Number.isSafeInteger(windowChunks) || windowChunks <= 0 || windowChunks > MAX_TRANSFER_WINDOW_CHUNKS)
|
|
706
|
+
throw new Error(`windowChunks must be within 1..${MAX_TRANSFER_WINDOW_CHUNKS}`);
|
|
707
|
+
if (!Number.isSafeInteger(maxRecoveryAttempts) || maxRecoveryAttempts < 0 || maxRecoveryAttempts > MAX_PROTOCOL_RECOVERY_ATTEMPTS)
|
|
708
|
+
throw new Error(`maxRecoveryAttempts must be within 0..${MAX_PROTOCOL_RECOVERY_ATTEMPTS}`);
|
|
709
|
+
if (!Number.isSafeInteger(ackTimeoutMs) || ackTimeoutMs <= 0)
|
|
710
|
+
throw new Error('ackTimeoutMs must be positive');
|
|
560
711
|
if (channel)
|
|
561
712
|
client.channels.register(channel);
|
|
562
713
|
const incoming = new Map();
|
|
714
|
+
// Admission is asynchronous too: serialize offers before a sink exists.
|
|
715
|
+
const incomingWork = new Map();
|
|
563
716
|
const completed = new Map();
|
|
564
717
|
const controlListeners = new Map();
|
|
565
718
|
let receiveOptions = null;
|
|
566
719
|
let disposed = false;
|
|
720
|
+
const receiveClaims = requiredCapabilities({ durableResume: options.receiveCapabilities?.durableResume,
|
|
721
|
+
extensions: options.receiveCapabilities?.extensions });
|
|
722
|
+
if (receiveClaims.durableResume && !options.createReceiveSink)
|
|
723
|
+
throw new Error('transfer_capability_required: durable resume requires a product streaming sink');
|
|
724
|
+
const localCapabilities = (receiving) => ({
|
|
725
|
+
...receiveClaims, receiveAvailable: receiving, durableResume: receiving && receiveClaims.durableResume,
|
|
726
|
+
extensions: receiving ? receiveClaims.extensions : [],
|
|
727
|
+
completionReceipt: typeof options.hasCompletionReceipt === 'function',
|
|
728
|
+
maxFileBytes: receiving ? (options.createReceiveSink ? maxFileBytes : Math.min(maxFileBytes, LEGACY_MAX_FILE_BYTES)) : 0,
|
|
729
|
+
});
|
|
730
|
+
const lifetime = new AbortController();
|
|
731
|
+
const activeWaits = new Set();
|
|
567
732
|
const connectionMatches = (connection, id) => [connection.id, connection.remoteNodeId, connection.deviceId]
|
|
568
733
|
.some((candidate) => typeof candidate === 'string' && candidate === id);
|
|
569
734
|
const runtimeClient = client;
|
|
@@ -624,14 +789,50 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
624
789
|
const connection = typeof target === 'string' ? await resolveConnection(target) : target;
|
|
625
790
|
await connection.send(encodeFrame(frame, maxHeaderBytes, chunkBytes));
|
|
626
791
|
};
|
|
627
|
-
const waitForControl = async (transferId, predicate, send, signal, expectedPeer) => {
|
|
628
|
-
|
|
792
|
+
const waitForControl = async (transferId, predicate, send, signal, expectedPeer, waiting) => {
|
|
793
|
+
throwIfAborted(signal);
|
|
794
|
+
throwIfAborted(lifetime.signal);
|
|
795
|
+
const identity = expectedPeer ? await client.peers?.identity?.(expectedPeer) : null;
|
|
629
796
|
const expectedNode = identity?.nodeId ?? expectedPeer;
|
|
630
|
-
|
|
631
|
-
|
|
797
|
+
throwIfAborted(signal);
|
|
798
|
+
throwIfAborted(lifetime.signal);
|
|
799
|
+
let resolveFrame, rejectFrame;
|
|
800
|
+
let settled = false, started = false, waitUntil = 0;
|
|
801
|
+
let timer;
|
|
802
|
+
const operation = new Promise((resolve, reject) => {
|
|
803
|
+
resolveFrame = frame => { if (!settled) {
|
|
804
|
+
settled = true;
|
|
805
|
+
resolve(frame);
|
|
806
|
+
} };
|
|
807
|
+
rejectFrame = error => { if (!settled) {
|
|
808
|
+
settled = true;
|
|
809
|
+
reject(error);
|
|
810
|
+
} };
|
|
811
|
+
});
|
|
812
|
+
// A cancellation can arrive while local policy/backpressure is still
|
|
813
|
+
// being awaited. Observe the verdict immediately without starting its
|
|
814
|
+
// remote-ACK deadline before body submission finishes.
|
|
815
|
+
void operation.catch(() => undefined);
|
|
816
|
+
const arm = () => {
|
|
817
|
+
if (settled || !started)
|
|
818
|
+
return;
|
|
819
|
+
if (timer !== undefined)
|
|
820
|
+
clearTimeout(timer);
|
|
821
|
+
timer = setTimeout(() => rejectFrame(new Error(`file transfer acknowledgement timed out after ${ackTimeoutMs}ms`)), Math.max(ackTimeoutMs, waitUntil - performance.now()));
|
|
822
|
+
};
|
|
823
|
+
const onAbort = () => rejectFrame(signal?.aborted ? signal.reason : lifetime.signal.reason);
|
|
824
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
825
|
+
lifetime.signal.addEventListener('abort', onAbort, { once: true });
|
|
632
826
|
const listener = (frame, peerId) => {
|
|
633
|
-
if (
|
|
827
|
+
if (expectedNode && peerId !== expectedNode)
|
|
634
828
|
return;
|
|
829
|
+
if (frame.kind === 'waiting') {
|
|
830
|
+
if (waiting && frame.offset >= waiting.minimumOffset && frame.offset <= waiting.maximumOffset) {
|
|
831
|
+
waitUntil = performance.now() + frame.leaseMs;
|
|
832
|
+
arm();
|
|
833
|
+
}
|
|
834
|
+
return; // Waiting never acknowledges bytes or completion.
|
|
835
|
+
}
|
|
635
836
|
if (predicate(frame))
|
|
636
837
|
resolveFrame(frame);
|
|
637
838
|
};
|
|
@@ -644,22 +845,26 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
644
845
|
await send();
|
|
645
846
|
}
|
|
646
847
|
catch (error) {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
// protocol listener alive for the bounded peer-ACK window: the
|
|
650
|
-
// receiver may already be replying on the replacement route.
|
|
848
|
+
if (error instanceof TransferBodyAdmissionFailure || signal?.aborted || lifetime.signal.aborted)
|
|
849
|
+
throw error;
|
|
651
850
|
sendError = error;
|
|
652
851
|
}
|
|
852
|
+
started = true;
|
|
853
|
+
arm();
|
|
653
854
|
try {
|
|
654
|
-
return await
|
|
855
|
+
return await operation;
|
|
655
856
|
}
|
|
656
857
|
catch (error) {
|
|
657
|
-
if (signal?.aborted)
|
|
858
|
+
if (signal?.aborted || lifetime.signal.aborted)
|
|
658
859
|
throw error;
|
|
659
860
|
throw sendError ?? error;
|
|
660
861
|
}
|
|
661
862
|
}
|
|
662
863
|
finally {
|
|
864
|
+
if (timer !== undefined)
|
|
865
|
+
clearTimeout(timer);
|
|
866
|
+
signal?.removeEventListener('abort', onAbort);
|
|
867
|
+
lifetime.signal.removeEventListener('abort', onAbort);
|
|
663
868
|
listeners.delete(listener);
|
|
664
869
|
if (!listeners.size)
|
|
665
870
|
controlListeners.delete(transferId);
|
|
@@ -673,11 +878,80 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
673
878
|
receiveOptions?.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
674
879
|
}
|
|
675
880
|
};
|
|
881
|
+
const receiveWork = async (state, configured, work) => {
|
|
882
|
+
if (state.sink && !options.beforeReceiveBody && !state.sink.bodyPolicy)
|
|
883
|
+
return work(configured.signal ?? lifetime.signal);
|
|
884
|
+
const controller = new AbortController();
|
|
885
|
+
const parents = [configured.signal, lifetime.signal, state.sink?.bodyPolicy?.signal].filter((signal) => !!signal);
|
|
886
|
+
const onAbort = () => controller.abort(parents.find(signal => signal.aborted)?.reason);
|
|
887
|
+
const negotiated = supportsReceiverWait(state.header);
|
|
888
|
+
const active = { owner: configured, cancel: () => controller.abort(new Error('execution_suspended: receive execution retired')) };
|
|
889
|
+
activeWaits.add(active);
|
|
890
|
+
let timer;
|
|
891
|
+
const pulse = async () => {
|
|
892
|
+
throwIfAborted(controller.signal);
|
|
893
|
+
if (!negotiated)
|
|
894
|
+
return;
|
|
895
|
+
await sendFrame(state.connection, { kind: 'waiting', transferId: state.header.transferId,
|
|
896
|
+
offset: state.confirmed, leaseMs: receiveWaitLeaseMs });
|
|
897
|
+
throwIfAborted(controller.signal);
|
|
898
|
+
};
|
|
899
|
+
const renew = async () => {
|
|
900
|
+
try {
|
|
901
|
+
await pulse();
|
|
902
|
+
timer = setTimeout(() => { void renew(); }, Math.max(1, Math.floor(receiveWaitLeaseMs / 3)));
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
controller.abort(error);
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
let rejectAbort;
|
|
909
|
+
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
|
910
|
+
void aborted.catch(() => undefined);
|
|
911
|
+
const onLocalAbort = () => rejectAbort(controller.signal.reason);
|
|
912
|
+
controller.signal.addEventListener('abort', onLocalAbort, { once: true });
|
|
913
|
+
for (const signal of parents)
|
|
914
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
915
|
+
try {
|
|
916
|
+
if (parents.some(signal => signal.aborted))
|
|
917
|
+
onAbort();
|
|
918
|
+
throwIfAborted(controller.signal);
|
|
919
|
+
await pulse();
|
|
920
|
+
if (negotiated)
|
|
921
|
+
timer = setTimeout(() => { void renew(); }, Math.max(1, Math.floor(receiveWaitLeaseMs / 3)));
|
|
922
|
+
const result = await Promise.race([work(controller.signal), aborted]);
|
|
923
|
+
throwIfAborted(controller.signal);
|
|
924
|
+
return result;
|
|
925
|
+
}
|
|
926
|
+
finally {
|
|
927
|
+
if (timer !== undefined)
|
|
928
|
+
clearTimeout(timer);
|
|
929
|
+
controller.abort(new Error('Receive policy wait ended'));
|
|
930
|
+
controller.signal.removeEventListener('abort', onLocalAbort);
|
|
931
|
+
for (const signal of parents)
|
|
932
|
+
signal.removeEventListener('abort', onAbort);
|
|
933
|
+
activeWaits.delete(active);
|
|
934
|
+
}
|
|
935
|
+
};
|
|
676
936
|
const processIncoming = async (connection, remoteNodeId, frame) => {
|
|
677
937
|
const configured = receiveOptions;
|
|
938
|
+
const key = JSON.stringify([remoteNodeId, routedTransferId(frame)]);
|
|
939
|
+
if (frame.kind === 'capabilities-request') {
|
|
940
|
+
if (frame.header) {
|
|
941
|
+
const retained = completed.get(key);
|
|
942
|
+
if (retained && retained.header !== JSON.stringify(frame.header))
|
|
943
|
+
throw new Error('transfer_identity_conflict: capability probe differs from completed offer');
|
|
944
|
+
if (retained || await options.hasCompletionReceipt?.(frame.header, remoteNodeId)) {
|
|
945
|
+
await respond(connection, { kind: 'completed', transferId: frame.transferId, size: frame.header.size });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
await respond(connection, { kind: 'capabilities', transferId: frame.transferId,
|
|
950
|
+
capabilities: localCapabilities(Boolean(configured) && !configured?.signal?.aborted) });
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
678
953
|
if (!configured)
|
|
679
954
|
return;
|
|
680
|
-
const key = JSON.stringify([remoteNodeId, routedTransferId(frame)]);
|
|
681
955
|
if (frame.kind === 'offer') {
|
|
682
956
|
const alreadyCompleted = completed.get(key);
|
|
683
957
|
if (alreadyCompleted) {
|
|
@@ -690,25 +964,81 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
690
964
|
if (existing) {
|
|
691
965
|
if (JSON.stringify(existing.header) !== JSON.stringify(frame.header))
|
|
692
966
|
throw new Error('transfer ID reused with a different header');
|
|
967
|
+
await existing.sink.checkpoint?.();
|
|
968
|
+
existing.confirmed = existing.received;
|
|
693
969
|
await respond(connection, { kind: 'resume', transferId: frame.header.transferId, offset: existing.received });
|
|
694
970
|
return;
|
|
695
971
|
}
|
|
696
972
|
try {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
973
|
+
if (await options.hasCompletionReceipt?.(frame.header, remoteNodeId)) {
|
|
974
|
+
completed.set(key, { size: frame.header.size, header: JSON.stringify(frame.header) });
|
|
975
|
+
if (completed.size > 1024)
|
|
976
|
+
completed.delete(completed.keys().next().value);
|
|
977
|
+
await respond(connection, { kind: 'completed', transferId: frame.header.transferId, size: frame.header.size });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
requireCapabilities(localCapabilities(true), frame.header);
|
|
981
|
+
if (options.beforeReceiveBody && !supportsReceiverWait(frame.header))
|
|
982
|
+
throw new Error('transfer_capability_required: receiver pacing requires negotiated wait support');
|
|
983
|
+
throwIfAborted(configured.signal);
|
|
984
|
+
const sink = await receiveWork({ header: frame.header, connection, confirmed: 0 }, configured, async (signal) => {
|
|
985
|
+
const opened = options.createReceiveSink
|
|
986
|
+
? await options.createReceiveSink(frame.header, remoteNodeId, signal)
|
|
987
|
+
: bufferedSink(frame.header, remoteNodeId);
|
|
988
|
+
if (signal.aborted) {
|
|
989
|
+
await opened.abort(signal.reason);
|
|
990
|
+
throw signal.reason;
|
|
991
|
+
}
|
|
992
|
+
return opened;
|
|
993
|
+
});
|
|
994
|
+
let received;
|
|
995
|
+
try {
|
|
996
|
+
throwIfAborted(configured.signal);
|
|
997
|
+
throwIfAborted(lifetime.signal);
|
|
998
|
+
if (receiveOptions !== configured)
|
|
999
|
+
throw new Error('execution_suspended: receive execution retired');
|
|
1000
|
+
throwIfAborted(sink.bodyPolicy?.signal);
|
|
1001
|
+
if (sink.bodyPolicy?.requiresReceiverWait && !supportsReceiverWait(frame.header))
|
|
1002
|
+
throw new Error('transfer_capability_required: receiver pacing requires negotiated wait support');
|
|
1003
|
+
if (receiveClaims.durableResume && !sink.checkpoint)
|
|
1004
|
+
throw new Error('transfer_capability_required: durable resume requires a checkpointing sink');
|
|
1005
|
+
received = sink.resumeOffset ?? 0;
|
|
1006
|
+
if (!Number.isSafeInteger(received) || received < 0 || received > frame.header.size)
|
|
1007
|
+
throw new Error('receive sink returned an invalid resumeOffset');
|
|
1008
|
+
}
|
|
1009
|
+
catch (error) {
|
|
1010
|
+
await sink.abort(error instanceof Error ? error : new Error(String(error)));
|
|
1011
|
+
throw error;
|
|
703
1012
|
}
|
|
704
|
-
|
|
1013
|
+
const state = {
|
|
705
1014
|
header: frame.header,
|
|
706
1015
|
remoteNodeId,
|
|
707
1016
|
connection,
|
|
708
1017
|
sink,
|
|
709
1018
|
received,
|
|
710
|
-
|
|
711
|
-
}
|
|
1019
|
+
confirmed: received,
|
|
1020
|
+
};
|
|
1021
|
+
incoming.set(key, state);
|
|
1022
|
+
const policySignal = sink.bodyPolicy?.signal;
|
|
1023
|
+
if (policySignal) {
|
|
1024
|
+
const retire = () => {
|
|
1025
|
+
// Active work observes the same signal; serialize cleanup
|
|
1026
|
+
// behind it so no sink operation races its own abort.
|
|
1027
|
+
void (incomingWork.get(key) ?? Promise.resolve()).finally(async () => {
|
|
1028
|
+
if (incoming.get(key) !== state)
|
|
1029
|
+
return;
|
|
1030
|
+
incoming.delete(key);
|
|
1031
|
+
state.detachPolicy?.();
|
|
1032
|
+
await sink.abort(policySignal.reason).catch(() => undefined);
|
|
1033
|
+
await respond(state.connection, { kind: 'reject', transferId: state.header.transferId,
|
|
1034
|
+
reason: 'execution_suspended: receive execution retired' });
|
|
1035
|
+
}).catch(() => undefined);
|
|
1036
|
+
};
|
|
1037
|
+
policySignal.addEventListener('abort', retire, { once: true });
|
|
1038
|
+
state.detachPolicy = () => policySignal.removeEventListener('abort', retire);
|
|
1039
|
+
if (policySignal.aborted)
|
|
1040
|
+
retire();
|
|
1041
|
+
}
|
|
712
1042
|
configured.onProgress?.({ transferId: frame.header.transferId, transferredBytes: received, totalBytes: frame.header.size, direction: 'receive' });
|
|
713
1043
|
await respond(connection, { kind: 'resume', transferId: frame.header.transferId, offset: received });
|
|
714
1044
|
}
|
|
@@ -730,39 +1060,76 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
730
1060
|
}
|
|
731
1061
|
state.connection = connection;
|
|
732
1062
|
if (frame.kind === 'chunk') {
|
|
733
|
-
|
|
734
|
-
|
|
1063
|
+
await receiveWork(state, configured, async (signal) => {
|
|
1064
|
+
// Policy accounts every admitted body attempt, including a
|
|
1065
|
+
// retransmitted prefix. Deduplication changes logical progress,
|
|
1066
|
+
// never the actual payload already submitted by the peer.
|
|
1067
|
+
await options.beforeReceiveBody?.({ transferId: frame.transferId, remoteNodeId,
|
|
1068
|
+
offset: frame.offset, bytes: frame.bytes.byteLength, signal });
|
|
1069
|
+
await state.sink.bodyPolicy?.beforeAttempt({ transferId: frame.transferId, remoteNodeId,
|
|
1070
|
+
offset: frame.offset, bytes: frame.bytes.byteLength, signal });
|
|
1071
|
+
throwIfAborted(signal);
|
|
1072
|
+
if (frame.offset < state.received && frame.offset + frame.bytes.byteLength <= state.received) {
|
|
1073
|
+
if (frame.ackRequested) {
|
|
1074
|
+
await state.sink.checkpoint?.();
|
|
1075
|
+
throwIfAborted(signal);
|
|
1076
|
+
state.confirmed = state.received;
|
|
1077
|
+
await respond(connection, { kind: 'ack', transferId: frame.transferId, offset: state.received });
|
|
1078
|
+
}
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (frame.offset !== state.received || state.received + frame.bytes.byteLength > state.header.size) {
|
|
1082
|
+
await state.sink.checkpoint?.();
|
|
1083
|
+
throwIfAborted(signal);
|
|
1084
|
+
state.confirmed = state.received;
|
|
735
1085
|
await respond(connection, { kind: 'ack', transferId: frame.transferId, offset: state.received });
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
await state.sink.write(frame.bytes);
|
|
1089
|
+
throwIfAborted(signal);
|
|
1090
|
+
state.received += frame.bytes.byteLength;
|
|
1091
|
+
configured.onProgress?.({ transferId: frame.transferId, transferredBytes: state.received, totalBytes: state.header.size, direction: 'receive' });
|
|
1092
|
+
if (frame.ackRequested) {
|
|
1093
|
+
await state.sink.checkpoint?.();
|
|
1094
|
+
throwIfAborted(signal);
|
|
1095
|
+
state.confirmed = state.received;
|
|
1096
|
+
await respond(connection, { kind: 'ack', transferId: frame.transferId, offset: state.received });
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
747
1099
|
return;
|
|
748
1100
|
}
|
|
749
1101
|
if (frame.kind === 'complete') {
|
|
750
1102
|
if (frame.size !== state.header.size || state.received !== state.header.size) {
|
|
1103
|
+
await state.sink.checkpoint?.();
|
|
1104
|
+
state.confirmed = state.received;
|
|
751
1105
|
await respond(connection, { kind: 'ack', transferId: frame.transferId, offset: state.received });
|
|
752
1106
|
return;
|
|
753
1107
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1108
|
+
await receiveWork(state, configured, async (signal) => {
|
|
1109
|
+
await state.sink.checkpoint?.();
|
|
1110
|
+
state.confirmed = state.received;
|
|
1111
|
+
throwIfAborted(signal);
|
|
1112
|
+
const result = await state.sink.close();
|
|
1113
|
+
if (state.header.sha256) {
|
|
1114
|
+
const verified = state.sink.verifySha256
|
|
1115
|
+
? await state.sink.verifySha256(state.header.sha256)
|
|
1116
|
+
: result && typeof result === 'object' && 'blob' in result
|
|
1117
|
+
? await sha256(result.blob) === state.header.sha256.toLowerCase()
|
|
1118
|
+
: false;
|
|
1119
|
+
if (!verified)
|
|
1120
|
+
throw new Error('file transfer integrity check failed');
|
|
1121
|
+
}
|
|
1122
|
+
throwIfAborted(signal);
|
|
1123
|
+
await configured.onFile({ header: state.header, remoteNodeId: state.remoteNodeId, result });
|
|
1124
|
+
throwIfAborted(signal);
|
|
1125
|
+
await state.sink.commit?.();
|
|
1126
|
+
if (requiredTransferCapabilities(state.header)?.completionReceipt
|
|
1127
|
+
&& !await options.hasCompletionReceipt?.(state.header, state.remoteNodeId))
|
|
1128
|
+
throw new Error('transfer_capability_required: receiver did not persist the required completion receipt');
|
|
1129
|
+
throwIfAborted(signal);
|
|
1130
|
+
});
|
|
765
1131
|
incoming.delete(key);
|
|
1132
|
+
state.detachPolicy?.();
|
|
766
1133
|
completed.set(key, { size: frame.size, header: JSON.stringify(state.header) });
|
|
767
1134
|
if (completed.size > 1024)
|
|
768
1135
|
completed.delete(completed.keys().next().value);
|
|
@@ -800,28 +1167,30 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
800
1167
|
remoteNodeId: connection.remoteNodeId ?? null,
|
|
801
1168
|
controlListenerCount: listeners?.size ?? 0,
|
|
802
1169
|
});
|
|
803
|
-
if (listeners && ['resume', 'ack', 'completed', 'reject'].includes(frame.kind)) {
|
|
1170
|
+
if (listeners && ['resume', 'ack', 'waiting', 'completed', 'reject', 'capabilities'].includes(frame.kind)) {
|
|
804
1171
|
for (const listener of [...listeners])
|
|
805
1172
|
listener(frame, connection.remoteNodeId ?? connection.deviceId ?? connection.id ?? 'unknown');
|
|
806
1173
|
}
|
|
807
|
-
if (['resume', 'ack', 'completed', 'reject'].includes(frame.kind))
|
|
1174
|
+
if (['resume', 'ack', 'waiting', 'completed', 'reject', 'capabilities'].includes(frame.kind))
|
|
808
1175
|
return;
|
|
809
1176
|
const routeConnection = connection;
|
|
810
1177
|
const remoteNodeId = routeConnection.remoteNodeId ?? routeConnection.deviceId ?? routeConnection.id ?? 'unknown';
|
|
811
1178
|
const key = JSON.stringify([remoteNodeId, frameId]);
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
1179
|
+
const chain = (incomingWork.get(key) ?? Promise.resolve())
|
|
1180
|
+
.then(() => { if (!disposed)
|
|
1181
|
+
return processIncoming(routeConnection, remoteNodeId, frame); })
|
|
815
1182
|
.catch(async (error) => {
|
|
816
1183
|
const state = incoming.get(key);
|
|
817
1184
|
incoming.delete(key);
|
|
1185
|
+
state?.detachPolicy?.();
|
|
818
1186
|
await state?.sink.abort(error).catch(() => undefined);
|
|
819
1187
|
receiveOptions?.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
820
1188
|
await respond(routeConnection, { kind: 'reject', transferId: routedTransferId(frame), reason: String(error) });
|
|
821
1189
|
});
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1190
|
+
incomingWork.set(key, chain);
|
|
1191
|
+
const retireWork = () => { if (incomingWork.get(key) === chain)
|
|
1192
|
+
incomingWork.delete(key); };
|
|
1193
|
+
void chain.then(retireWork, retireWork);
|
|
825
1194
|
};
|
|
826
1195
|
const messageSource = client;
|
|
827
1196
|
let offMessage;
|
|
@@ -837,7 +1206,7 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
837
1206
|
try {
|
|
838
1207
|
const prefix = await waitWithDeadline(reader.exact(4), ackTimeoutMs);
|
|
839
1208
|
const size = new DataView(prefix.buffer, prefix.byteOffset, 4).getUint32(0, false);
|
|
840
|
-
const limit = Math.max(maxHeaderBytes + 4, chunkBytes + 9) + MAX_ROUTED_TRANSFER_ID_BYTES + 7;
|
|
1209
|
+
const limit = Math.max(maxHeaderBytes + 4, MAX_CAPABILITY_BYTES + 4, chunkBytes + 9) + MAX_ROUTED_TRANSFER_ID_BYTES + 7;
|
|
841
1210
|
if (!size || size > limit)
|
|
842
1211
|
throw new Error('invalid file transfer segment length');
|
|
843
1212
|
const bytes = await waitWithDeadline(reader.exact(size), ackTimeoutMs);
|
|
@@ -872,106 +1241,191 @@ export function createRoutedTransfer(client, options = {}) {
|
|
|
872
1241
|
attach(connection);
|
|
873
1242
|
offMessage = client.diagnostics.onConnection((connection) => attach(connection));
|
|
874
1243
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1244
|
+
const sendOffered = async (deviceId, proposed, openSource, sendOptions, verifySource) => {
|
|
1245
|
+
if (disposed)
|
|
1246
|
+
throw new Error('routed file-transfer protocol is disposed');
|
|
1247
|
+
throwIfAborted(sendOptions.signal);
|
|
1248
|
+
const encoded = encodeHeader(proposed, maxHeaderBytes);
|
|
1249
|
+
const header = parseHeader(encoded.subarray(4), verifySource ? Number.MAX_SAFE_INTEGER : maxFileBytes);
|
|
1250
|
+
if (header.offset !== undefined)
|
|
1251
|
+
throw new Error('routed immutable offers cannot contain a sender resume offset');
|
|
1252
|
+
if (verifySource && !header.sha256)
|
|
1253
|
+
throw new Error('accepted file transfer requires an immutable SHA-256 revision');
|
|
1254
|
+
const id = header.transferId;
|
|
1255
|
+
let opened;
|
|
1256
|
+
const sourceFile = () => opened ?? (opened = (async () => {
|
|
881
1257
|
throwIfAborted(sendOptions.signal);
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
throwIfAborted(sendOptions.signal);
|
|
896
|
-
try {
|
|
897
|
-
return await operation();
|
|
898
|
-
}
|
|
899
|
-
catch (error) {
|
|
900
|
-
if (sendOptions.signal?.aborted || error instanceof TransferRejectedError || attempt >= maxRecoveryAttempts)
|
|
901
|
-
throw error;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
};
|
|
905
|
-
const offer = async () => {
|
|
906
|
-
const response = await waitForControl(id, (frame) => frame.kind === 'resume' || frame.kind === 'completed' || frame.kind === 'reject', () => sendFrame(deviceId, { kind: 'offer', header }), sendOptions.signal, deviceId);
|
|
907
|
-
if (response.kind === 'reject')
|
|
908
|
-
throw new TransferRejectedError(`file transfer rejected: ${response.reason}`);
|
|
909
|
-
if (response.kind === 'completed')
|
|
910
|
-
return response.size;
|
|
911
|
-
if (response.kind !== 'resume' || response.offset > file.size)
|
|
912
|
-
throw new Error('invalid file transfer resume response');
|
|
913
|
-
return response.offset;
|
|
914
|
-
};
|
|
915
|
-
let confirmed = await recover(offer);
|
|
916
|
-
sendOptions.onProgress?.({ transferId: id, transferredBytes: confirmed, totalBytes: file.size, direction: 'send' });
|
|
917
|
-
while (confirmed < file.size) {
|
|
1258
|
+
const file = await openSource();
|
|
1259
|
+
if (file.size !== header.size || file.name !== header.name
|
|
1260
|
+
|| (file.type || 'application/octet-stream') !== header.mimeType)
|
|
1261
|
+
throw new Error('transfer_identity_conflict: source differs from the accepted offer');
|
|
1262
|
+
if (verifySource && await sha256(file, sendOptions.signal) !== header.sha256.toLowerCase())
|
|
1263
|
+
throw new Error('transfer_identity_conflict: source revision differs from the accepted offer');
|
|
1264
|
+
throwIfAborted(sendOptions.signal);
|
|
1265
|
+
return file;
|
|
1266
|
+
})());
|
|
1267
|
+
let receiptConfirmed = false;
|
|
1268
|
+
let recoveryAttempts = 0;
|
|
1269
|
+
const recover = async (operation) => {
|
|
1270
|
+
for (let attempt = 0;; attempt += 1) {
|
|
918
1271
|
throwIfAborted(sendOptions.signal);
|
|
919
|
-
const windowStart = confirmed;
|
|
920
|
-
let cursor = confirmed;
|
|
921
|
-
const frames = [];
|
|
922
|
-
for (let index = 0; index < windowChunks && cursor < file.size; index += 1) {
|
|
923
|
-
const bytes = new Uint8Array(await file.slice(cursor, cursor + chunkBytes).arrayBuffer());
|
|
924
|
-
const end = cursor + bytes.byteLength;
|
|
925
|
-
frames.push({ kind: 'chunk', transferId: id, offset: cursor, ackRequested: end === file.size || index === windowChunks - 1, bytes });
|
|
926
|
-
cursor = end;
|
|
927
|
-
}
|
|
928
1272
|
try {
|
|
929
|
-
|
|
930
|
-
for (const frame of frames)
|
|
931
|
-
await sendFrame(deviceId, frame);
|
|
932
|
-
}, sendOptions.signal, deviceId);
|
|
933
|
-
if (response.kind === 'reject')
|
|
934
|
-
throw new TransferRejectedError(`file transfer rejected: ${response.reason}`);
|
|
935
|
-
if (response.kind !== 'ack' || response.offset < windowStart || response.offset > cursor) {
|
|
936
|
-
throw new Error('invalid file transfer offset acknowledgement');
|
|
937
|
-
}
|
|
938
|
-
confirmed = response.offset;
|
|
939
|
-
recoveryAttempts = 0;
|
|
940
|
-
sendOptions.onProgress?.({ transferId: id, transferredBytes: confirmed, totalBytes: file.size, direction: 'send' });
|
|
1273
|
+
return await operation();
|
|
941
1274
|
}
|
|
942
1275
|
catch (error) {
|
|
943
|
-
if (sendOptions.signal?.aborted || error instanceof TransferRejectedError)
|
|
1276
|
+
if (sendOptions.signal?.aborted || error instanceof TransferRejectedError || attempt >= maxRecoveryAttempts)
|
|
944
1277
|
throw error;
|
|
945
|
-
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
const offer = async () => {
|
|
1282
|
+
if (header.size > LEGACY_MAX_FILE_BYTES || header.size > maxFileBytes || requiredTransferCapabilities(header)) {
|
|
1283
|
+
let verdict;
|
|
1284
|
+
try {
|
|
1285
|
+
verdict = await waitForControl(id, frame => frame.kind === 'capabilities' || frame.kind === 'reject'
|
|
1286
|
+
|| frame.kind === 'completed' && frame.size === header.size, () => sendFrame(deviceId, { kind: 'capabilities-request', transferId: id, header }), sendOptions.signal, deviceId);
|
|
1287
|
+
}
|
|
1288
|
+
catch (error) {
|
|
1289
|
+
if (sendOptions.signal?.aborted || lifetime.signal.aborted)
|
|
946
1290
|
throw error;
|
|
947
|
-
|
|
948
|
-
|
|
1291
|
+
throw new Error('transfer_capability_required: receiver capability response unavailable; upgrade the receiving client or retry when it can receive');
|
|
1292
|
+
}
|
|
1293
|
+
if (verdict.kind === 'completed') {
|
|
1294
|
+
receiptConfirmed = true;
|
|
1295
|
+
return header.size;
|
|
1296
|
+
}
|
|
1297
|
+
if (verdict.kind === 'reject')
|
|
1298
|
+
throw new TransferRejectedError(verdict.reason);
|
|
1299
|
+
if (verdict.kind !== 'capabilities')
|
|
1300
|
+
throw new TransferRejectedError('transfer_capability_required: invalid receiver capability response');
|
|
1301
|
+
try {
|
|
1302
|
+
requireCapabilities(verdict.capabilities, header);
|
|
1303
|
+
}
|
|
1304
|
+
catch (error) {
|
|
1305
|
+
throw new TransferRejectedError(error.message);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
if (header.size > maxFileBytes)
|
|
1309
|
+
throw new TransferRejectedError(`file exceeds ${maxFileBytes} byte transfer limit`);
|
|
1310
|
+
const response = await waitForControl(id, (frame) => frame.kind === 'resume' || frame.kind === 'completed' || frame.kind === 'reject', () => sendFrame(deviceId, { kind: 'offer', header }), sendOptions.signal, deviceId, supportsReceiverWait(header) ? { minimumOffset: 0, maximumOffset: 0 } : undefined);
|
|
1311
|
+
if (response.kind === 'reject')
|
|
1312
|
+
throw new TransferRejectedError(`file transfer rejected: ${response.reason}`);
|
|
1313
|
+
if (response.kind === 'completed') {
|
|
1314
|
+
if (response.size !== header.size)
|
|
1315
|
+
throw new Error('invalid completed file transfer size');
|
|
1316
|
+
receiptConfirmed = true;
|
|
1317
|
+
return response.size;
|
|
1318
|
+
}
|
|
1319
|
+
if (response.kind !== 'resume' || response.offset > header.size)
|
|
1320
|
+
throw new Error('invalid file transfer resume response');
|
|
1321
|
+
return response.offset;
|
|
1322
|
+
};
|
|
1323
|
+
let confirmed = await recover(offer);
|
|
1324
|
+
sendOptions.onProgress?.({ transferId: id, transferredBytes: confirmed, totalBytes: header.size, direction: 'send' });
|
|
1325
|
+
while (confirmed < header.size) {
|
|
1326
|
+
const file = await sourceFile();
|
|
1327
|
+
throwIfAborted(sendOptions.signal);
|
|
1328
|
+
const windowStart = confirmed;
|
|
1329
|
+
let cursor = confirmed;
|
|
1330
|
+
const frames = [];
|
|
1331
|
+
for (let index = 0; index < windowChunks && cursor < header.size; index += 1) {
|
|
1332
|
+
const bytes = new Uint8Array(await file.slice(cursor, cursor + chunkBytes).arrayBuffer());
|
|
1333
|
+
const end = cursor + bytes.byteLength;
|
|
1334
|
+
frames.push({ kind: 'chunk', transferId: id, offset: cursor, ackRequested: end === header.size || index === windowChunks - 1, bytes });
|
|
1335
|
+
cursor = end;
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
const response = await waitForControl(id, (frame) => (frame.kind === 'ack' && frame.offset >= windowStart) || frame.kind === 'reject', async () => {
|
|
1339
|
+
for (const frame of frames) {
|
|
1340
|
+
if (frame.kind === 'chunk') {
|
|
1341
|
+
try {
|
|
1342
|
+
await options.beforeSendBody?.({ transferId: id, peerId: deviceId,
|
|
1343
|
+
bytes: frame.bytes.byteLength, offset: frame.offset, signal: sendOptions.signal });
|
|
1344
|
+
await sendOptions.beforeSendBody?.({ transferId: id, peerId: deviceId,
|
|
1345
|
+
bytes: frame.bytes.byteLength, offset: frame.offset, signal: sendOptions.signal });
|
|
1346
|
+
}
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
throw new TransferBodyAdmissionFailure(error);
|
|
1349
|
+
}
|
|
1350
|
+
throwIfAborted(sendOptions.signal);
|
|
1351
|
+
}
|
|
1352
|
+
await sendFrame(deviceId, frame);
|
|
1353
|
+
}
|
|
1354
|
+
}, sendOptions.signal, deviceId, supportsReceiverWait(header) ? { minimumOffset: windowStart, maximumOffset: cursor } : undefined);
|
|
1355
|
+
if (response.kind === 'reject')
|
|
1356
|
+
throw new TransferRejectedError(`file transfer rejected: ${response.reason}`);
|
|
1357
|
+
if (response.kind !== 'ack' || response.offset < windowStart || response.offset > cursor) {
|
|
1358
|
+
throw new Error('invalid file transfer offset acknowledgement');
|
|
949
1359
|
}
|
|
1360
|
+
confirmed = response.offset;
|
|
1361
|
+
recoveryAttempts = 0;
|
|
1362
|
+
sendOptions.onProgress?.({ transferId: id, transferredBytes: confirmed, totalBytes: header.size, direction: 'send' });
|
|
950
1363
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
if (error instanceof TransferBodyAdmissionFailure)
|
|
1366
|
+
throw error.failure;
|
|
1367
|
+
if (sendOptions.signal?.aborted || error instanceof TransferRejectedError)
|
|
1368
|
+
throw error;
|
|
1369
|
+
if (recoveryAttempts >= maxRecoveryAttempts)
|
|
1370
|
+
throw error;
|
|
1371
|
+
recoveryAttempts += 1;
|
|
1372
|
+
confirmed = await recover(offer);
|
|
956
1373
|
}
|
|
1374
|
+
}
|
|
1375
|
+
if (receiptConfirmed)
|
|
957
1376
|
return id;
|
|
1377
|
+
const completion = await recover(() => waitForControl(id, (frame) => frame.kind === 'completed' || frame.kind === 'ack' || frame.kind === 'reject', () => sendFrame(deviceId, { kind: 'complete', transferId: id, size: header.size }), sendOptions.signal, deviceId, supportsReceiverWait(header) ? { minimumOffset: header.size, maximumOffset: header.size } : undefined));
|
|
1378
|
+
if (completion.kind === 'reject')
|
|
1379
|
+
throw new TransferRejectedError(`file transfer rejected: ${completion.reason}`);
|
|
1380
|
+
if (completion.kind !== 'completed' || completion.size !== header.size) {
|
|
1381
|
+
throw new Error('receiver did not confirm completed file transfer');
|
|
1382
|
+
}
|
|
1383
|
+
return id;
|
|
1384
|
+
};
|
|
1385
|
+
return {
|
|
1386
|
+
async sendAccepted(deviceId, source, sendOptions = {}) {
|
|
1387
|
+
return sendOffered(deviceId, source.header, () => source.openSource(), sendOptions, true);
|
|
1388
|
+
},
|
|
1389
|
+
async send(deviceId, file, sendOptions = {}) {
|
|
1390
|
+
if (file.size > maxFileBytes)
|
|
1391
|
+
throw new Error(`file exceeds ${maxFileBytes} byte transfer limit`);
|
|
1392
|
+
throwIfAborted(sendOptions.signal);
|
|
1393
|
+
const proposed = withReceiverWaitCapability({
|
|
1394
|
+
version: 1, transferId: sendOptions.transferId ?? transferId(), name: file.name,
|
|
1395
|
+
size: file.size, mimeType: file.type || 'application/octet-stream',
|
|
1396
|
+
metadata: sendOptions.metadata,
|
|
1397
|
+
sha256: sendOptions.integrity === 'sha256' ? await sha256(file, sendOptions.signal) : undefined,
|
|
1398
|
+
});
|
|
1399
|
+
const header = sendOptions.requiredCapabilities
|
|
1400
|
+
? withRequiredCapabilities(proposed, sendOptions.requiredCapabilities) : proposed;
|
|
1401
|
+
return sendOffered(deviceId, header, async () => file, sendOptions, false);
|
|
958
1402
|
},
|
|
959
1403
|
onIncoming(configured) {
|
|
1404
|
+
if (receiveOptions !== configured)
|
|
1405
|
+
for (const wait of activeWaits)
|
|
1406
|
+
if (wait.owner === receiveOptions)
|
|
1407
|
+
wait.cancel();
|
|
960
1408
|
receiveOptions = configured;
|
|
961
1409
|
return () => {
|
|
962
1410
|
if (receiveOptions === configured)
|
|
963
1411
|
receiveOptions = null;
|
|
1412
|
+
for (const wait of activeWaits)
|
|
1413
|
+
if (wait.owner === configured)
|
|
1414
|
+
wait.cancel();
|
|
964
1415
|
};
|
|
965
1416
|
},
|
|
966
1417
|
dispose() {
|
|
967
1418
|
disposed = true;
|
|
1419
|
+
lifetime.abort(new Error('file-transfer protocol disposed'));
|
|
968
1420
|
offMessage();
|
|
969
1421
|
if (channel)
|
|
970
1422
|
client.channels.unregister(channel.id);
|
|
971
1423
|
receiveOptions = null;
|
|
972
1424
|
controlListeners.clear();
|
|
973
|
-
for (const state of incoming.values())
|
|
1425
|
+
for (const state of incoming.values()) {
|
|
1426
|
+
state.detachPolicy?.();
|
|
974
1427
|
void state.sink.abort(new Error('file-transfer protocol disposed'));
|
|
1428
|
+
}
|
|
975
1429
|
incoming.clear();
|
|
976
1430
|
},
|
|
977
1431
|
};
|