openrtc-file-transfer 2.9.7 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
- if (!globalThis.crypto?.subtle)
358
- throw new Error('SHA-256 integrity requires Web Crypto');
359
- const digest = await globalThis.crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
360
- return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
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('chunkBytes must be positive');
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, receiveOptions.signal);
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, receiveOptions.signal), maxFileBytes);
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(receiveOptions.signal);
499
- const chunk = await reader.some(Math.min(chunkBytes, header.size - received), receiveOptions.signal);
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
- await receiveOptions.onFile({ header, remoteNodeId: incoming.remoteNodeId, result });
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(windowChunks) || windowChunks <= 0)
557
- throw new Error('windowChunks must be positive');
558
- if (!Number.isSafeInteger(maxRecoveryAttempts) || maxRecoveryAttempts < 0)
559
- throw new Error('maxRecoveryAttempts must be non-negative');
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
- const identity = channel && expectedPeer ? await client.peers?.identity?.(expectedPeer) : null;
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
- let resolveFrame;
631
- const operation = new Promise((resolve) => { resolveFrame = resolve; });
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 (channel && peerId !== expectedNode)
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
- // A replaceable route can close after accepting the frame but
648
- // before its local delivery acknowledgement settles. Keep the
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 waitWithDeadline(operation, ackTimeoutMs, signal);
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
- const sink = options.createReceiveSink
698
- ? await options.createReceiveSink(frame.header, remoteNodeId)
699
- : bufferedSink(frame.header, remoteNodeId);
700
- const received = sink.resumeOffset ?? 0;
701
- if (!Number.isSafeInteger(received) || received < 0 || received > frame.header.size) {
702
- throw new Error('receive sink returned an invalid resumeOffset');
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
- incoming.set(key, {
1013
+ const state = {
705
1014
  header: frame.header,
706
1015
  remoteNodeId,
707
1016
  connection,
708
1017
  sink,
709
1018
  received,
710
- chain: Promise.resolve(),
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
- if (frame.offset < state.received && frame.offset + frame.bytes.byteLength <= state.received) {
734
- if (frame.ackRequested)
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
- return;
737
- }
738
- if (frame.offset !== state.received || state.received + frame.bytes.byteLength > state.header.size) {
739
- await respond(connection, { kind: 'ack', transferId: frame.transferId, offset: state.received });
740
- return;
741
- }
742
- await state.sink.write(frame.bytes);
743
- state.received += frame.bytes.byteLength;
744
- configured.onProgress?.({ transferId: frame.transferId, transferredBytes: state.received, totalBytes: state.header.size, direction: 'receive' });
745
- if (frame.ackRequested)
746
- await respond(connection, { kind: 'ack', transferId: frame.transferId, offset: state.received });
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
- const result = await state.sink.close();
755
- if (state.header.sha256) {
756
- const verified = state.sink.verifySha256
757
- ? await state.sink.verifySha256(state.header.sha256)
758
- : result && typeof result === 'object' && 'blob' in result
759
- ? await sha256(result.blob) === state.header.sha256.toLowerCase()
760
- : false;
761
- if (!verified)
762
- throw new Error('file transfer integrity check failed');
763
- }
764
- await configured.onFile({ header: state.header, remoteNodeId: state.remoteNodeId, result });
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 current = incoming.get(key);
813
- const chain = (current?.chain ?? Promise.resolve())
814
- .then(() => processIncoming(routeConnection, remoteNodeId, frame))
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
- if (current)
823
- current.chain = chain;
824
- void chain;
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
- return {
876
- async send(deviceId, file, sendOptions = {}) {
877
- if (disposed)
878
- throw new Error('routed file-transfer protocol is disposed');
879
- if (file.size > maxFileBytes)
880
- throw new Error(`file exceeds ${maxFileBytes} byte transfer limit`);
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 id = sendOptions.transferId ?? transferId();
883
- const header = {
884
- version: 1,
885
- transferId: id,
886
- name: file.name,
887
- size: file.size,
888
- mimeType: file.type || 'application/octet-stream',
889
- metadata: sendOptions.metadata,
890
- sha256: sendOptions.integrity === 'sha256' ? await sha256(file) : undefined,
891
- };
892
- let recoveryAttempts = 0;
893
- const recover = async (operation) => {
894
- for (let attempt = 0;; attempt += 1) {
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
- const response = await waitForControl(id, (frame) => (frame.kind === 'ack' && frame.offset >= windowStart) || frame.kind === 'reject', async () => {
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
- if (recoveryAttempts >= maxRecoveryAttempts)
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
- recoveryAttempts += 1;
948
- confirmed = await recover(offer);
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
- const completion = await recover(() => waitForControl(id, (frame) => frame.kind === 'completed' || frame.kind === 'ack' || frame.kind === 'reject', () => sendFrame(deviceId, { kind: 'complete', transferId: id, size: file.size }), sendOptions.signal, deviceId));
952
- if (completion.kind === 'reject')
953
- throw new TransferRejectedError(`file transfer rejected: ${completion.reason}`);
954
- if (completion.kind !== 'completed' || completion.size !== file.size) {
955
- throw new Error('receiver did not confirm completed file transfer');
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
  };