@lossless.org/client 1.14.0 → 1.16.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.
@@ -18,7 +18,8 @@ import {
18
18
  purgeExactPath as purgeExactPathOperation,
19
19
  } from './internal.exactpathpurge.operations.js';
20
20
  import { beginBasicBucketOperation } from './internal.exactupload.capability.js';
21
- import { headBucketExists, runAccountRequest } from './internal.bucketrequests.js';
21
+ import { getProviderStatus, headBucketExists, runAccountRequest } from './internal.bucketrequests.js';
22
+ import { ObjectRangeError } from './classes.objectrangeerror.js';
22
23
 
23
24
  export interface IListObjectKeysPageOptions {
24
25
  prefix?: string;
@@ -196,6 +197,58 @@ const toObjectListingEntry = (
196
197
  };
197
198
  };
198
199
 
200
+ const assertByteRangeOptions = (
201
+ optionsArg: interfaces.IFastGetRangeStreamOptions,
202
+ ): interfaces.IObjectByteRange => {
203
+ const refuse = (messageArg: string): never => {
204
+ throw new ObjectRangeError('invalid_argument', messageArg);
205
+ };
206
+ if (!optionsArg || typeof optionsArg !== 'object' || Array.isArray(optionsArg)) {
207
+ return refuse('fastGetRangeStream options must be a plain object.');
208
+ }
209
+ const range: unknown = optionsArg.range;
210
+ if (!range || typeof range !== 'object' || Array.isArray(range)) {
211
+ return refuse('range must be an object with a start and an optional end.');
212
+ }
213
+ const { start, end } = range as { start?: unknown; end?: unknown };
214
+ if (typeof start !== 'number' || !Number.isSafeInteger(start) || start < 0) {
215
+ return refuse('range.start must be a non-negative safe integer.');
216
+ }
217
+ if (end === undefined) {
218
+ return { start };
219
+ }
220
+ if (typeof end !== 'number' || !Number.isSafeInteger(end) || end < start) {
221
+ return refuse('range.end must be a safe integer that is not below range.start.');
222
+ }
223
+ return { start, end };
224
+ };
225
+
226
+ /**
227
+ * Reads the served range and the object size from `Content-Range`, and
228
+ * refuses a response that does not serve exactly the requested range, so a
229
+ * provider that ignored the Range header never hands out the whole object.
230
+ */
231
+ const readServedByteRange = (
232
+ responseArg: plugins.s3.GetObjectCommandOutput,
233
+ requestedArg: interfaces.IObjectByteRange,
234
+ ): { range: { start: number; end: number }; size: number } => {
235
+ const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(responseArg.ContentRange ?? '');
236
+ const [start, end, size] = match ? match.slice(1).map(Number) : [];
237
+ if (
238
+ start === undefined || end === undefined || size === undefined
239
+ || ![start, end, size].every(Number.isSafeInteger)
240
+ || start !== requestedArg.start
241
+ || end !== Math.min(requestedArg.end ?? Number.MAX_SAFE_INTEGER, size - 1)
242
+ || (responseArg.ContentLength !== undefined && responseArg.ContentLength !== end - start + 1)
243
+ ) {
244
+ throw new ObjectRangeError(
245
+ 'invalid_response',
246
+ 'The provider did not answer the byte-range request with a matching Content-Range.',
247
+ );
248
+ }
249
+ return { range: { start, end }, size };
250
+ };
251
+
199
252
  const destroyReadableAndWaitForSettlement = async (
200
253
  readableArg: plugins.stream.Readable,
201
254
  ): Promise<void> => {
@@ -460,6 +513,64 @@ export class Bucket {
460
513
  optionsArg: interfaces.IFastGetStreamOptions,
461
514
  typeArg: 'webstream' | 'nodestream' = 'nodestream'
462
515
  ): Promise<ReadableStream | plugins.stream.Readable> {
516
+ const { stream } = await this.openObjectReadStream(optionsArg, typeArg, undefined, () => undefined);
517
+ return stream;
518
+ }
519
+
520
+ /**
521
+ * Streams one inclusive byte range of an object with a single GetObject
522
+ * request and reports the range served and the object's total size from the
523
+ * provider's `Content-Range`, so an HTTP 206 answer needs no extra HEAD.
524
+ * An invalid range is refused with `ObjectRangeError` code
525
+ * `invalid_argument` before any request; a range that starts at or beyond
526
+ * the end of the object rejects with `range_not_satisfiable`.
527
+ */
528
+ public fastGetRangeStream(
529
+ optionsArg: interfaces.IFastGetRangeStreamOptions,
530
+ typeArg: 'webstream'
531
+ ): Promise<interfaces.IFastGetRangeStreamResult<ReadableStream>>;
532
+ public async fastGetRangeStream(
533
+ optionsArg: interfaces.IFastGetRangeStreamOptions,
534
+ typeArg?: 'nodestream'
535
+ ): Promise<interfaces.IFastGetRangeStreamResult<plugins.stream.Readable>>;
536
+
537
+ public async fastGetRangeStream(
538
+ optionsArg: interfaces.IFastGetRangeStreamOptions,
539
+ typeArg: 'webstream' | 'nodestream' = 'nodestream'
540
+ ): Promise<interfaces.IFastGetRangeStreamResult<ReadableStream | plugins.stream.Readable>> {
541
+ const requested = assertByteRangeOptions(optionsArg);
542
+ try {
543
+ const { stream, info } = await this.openObjectReadStream(
544
+ optionsArg,
545
+ typeArg,
546
+ `bytes=${requested.start}-${requested.end ?? ''}`,
547
+ (responseArg) => readServedByteRange(responseArg, requested),
548
+ );
549
+ return { stream, range: info.range, size: info.size };
550
+ } catch (error) {
551
+ const { name, httpStatusCode } = getProviderStatus(error);
552
+ if (httpStatusCode === 416 || name === 'InvalidRange') {
553
+ throw new ObjectRangeError(
554
+ 'range_not_satisfiable',
555
+ `The byte range starting at ${requested.start} is not satisfiable for '${optionsArg.path}'.`,
556
+ { cause: error },
557
+ );
558
+ }
559
+ throw error;
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Sends one GetObject, reads the response headers through `readResponseArg`
565
+ * before any byte is handed out, and returns the body as a stream that
566
+ * `optionsArg.signal` and `SmartBucket.close()` cancel.
567
+ */
568
+ private async openObjectReadStream<TInfo>(
569
+ optionsArg: interfaces.IFastGetStreamOptions,
570
+ typeArg: 'webstream' | 'nodestream',
571
+ rangeHeaderArg: string | undefined,
572
+ readResponseArg: (responseArg: plugins.s3.GetObjectCommandOutput) => TInfo,
573
+ ): Promise<{ stream: ReadableStream | plugins.stream.Readable; info: TInfo }> {
463
574
  const operation = beginBasicBucketOperation(this, optionsArg.signal);
464
575
  const disposeBody = async (responseBodyArg: unknown): Promise<void> => {
465
576
  try {
@@ -494,6 +605,7 @@ export class Bucket {
494
605
  new plugins.s3.GetObjectCommand({
495
606
  Bucket: operation.bucketName,
496
607
  Key: optionsArg.path,
608
+ ...(rangeHeaderArg === undefined ? {} : { Range: rangeHeaderArg }),
497
609
  }),
498
610
  { abortSignal: operation.signal },
499
611
  ),
@@ -511,6 +623,14 @@ export class Bucket {
511
623
  const disposeResponseBody = async (): Promise<void> => {
512
624
  await disposeBody(responseBody);
513
625
  };
626
+ let info: TInfo;
627
+ try {
628
+ info = readResponseArg(response);
629
+ } catch (error) {
630
+ await disposeResponseBody();
631
+ operation.finish();
632
+ throw error;
633
+ }
514
634
  let sourceStream: plugins.stream.Readable | null = null;
515
635
  try {
516
636
  sourceStream = responseBody instanceof plugins.stream.Readable
@@ -644,7 +764,7 @@ export class Bucket {
644
764
  operation.finish();
645
765
  throw error;
646
766
  }
647
- return result;
767
+ return { stream: result, info };
648
768
  }
649
769
 
650
770
  /**
@@ -0,0 +1,11 @@
1
+ import type { TObjectRangeErrorCode } from './interfaces.js';
2
+
3
+ export class ObjectRangeError extends Error {
4
+ public readonly code: TObjectRangeErrorCode;
5
+
6
+ constructor(codeArg: TObjectRangeErrorCode, messageArg: string, optionsArg?: ErrorOptions) {
7
+ super(messageArg, optionsArg);
8
+ this.name = 'ObjectRangeError';
9
+ this.code = codeArg;
10
+ }
11
+ }
@@ -2,6 +2,7 @@ export * from './classes.smartbucket.js';
2
2
  export * from './classes.bucket.js';
3
3
  export * from './classes.exactuploaderror.js';
4
4
  export * from './classes.exactpathpurgeerror.js';
5
+ export * from './classes.objectrangeerror.js';
5
6
  export * from './classes.directory.js';
6
7
  export * from './classes.file.js';
7
8
  export * from './classes.listcursor.js';
@@ -13,6 +13,43 @@ export interface IFastGetStreamOptions {
13
13
  signal?: AbortSignal;
14
14
  }
15
15
 
16
+ /**
17
+ * A byte range of an object, with HTTP semantics: `start` and `end` are
18
+ * zero-based and inclusive. Without `end` the range runs to the last byte.
19
+ */
20
+ export interface IObjectByteRange {
21
+ start: number;
22
+ end?: number;
23
+ }
24
+
25
+ export interface IFastGetRangeStreamOptions extends IFastGetStreamOptions {
26
+ range: IObjectByteRange;
27
+ }
28
+
29
+ /**
30
+ * One ranged download: the stream carries exactly the bytes of `range`.
31
+ * - `range`: the inclusive byte range the provider served, from its
32
+ * `Content-Range`; `end` is clamped to the last byte of the object.
33
+ * - `size`: the byte length of the whole object, from the same header.
34
+ */
35
+ export interface IFastGetRangeStreamResult<TStream> {
36
+ stream: TStream;
37
+ range: { start: number; end: number };
38
+ size: number;
39
+ }
40
+
41
+ /**
42
+ * - `invalid_argument`: the request was refused before it was sent.
43
+ * - `range_not_satisfiable`: the provider answered HTTP 416; the range starts
44
+ * at or beyond the end of the object.
45
+ * - `invalid_response`: the provider answered without a `Content-Range` that
46
+ * matches the requested range, so its bytes were not handed out.
47
+ */
48
+ export type TObjectRangeErrorCode =
49
+ | 'invalid_argument'
50
+ | 'range_not_satisfiable'
51
+ | 'invalid_response';
52
+
16
53
  export interface IFastPutStreamOptions {
17
54
  path: string;
18
55
  readableStream: Readable | ReadableStream;
@@ -19,7 +19,7 @@ export const runAccountRequest = async <T>(
19
19
  }
20
20
  };
21
21
 
22
- const getProviderStatus = (errorArg: unknown): { name?: string; httpStatusCode?: number } => {
22
+ export const getProviderStatus = (errorArg: unknown): { name?: string; httpStatusCode?: number } => {
23
23
  if (typeof errorArg !== 'object' || errorArg === null) {
24
24
  return {};
25
25
  }