@lossless.org/client 1.23.3 → 2.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.
Files changed (32) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/objectstorage/classes.bucket.d.ts +11 -21
  3. package/dist_ts/objectstorage/classes.bucket.js +58 -29
  4. package/dist_ts/objectstorage/classes.connection.d.ts +2 -1
  5. package/dist_ts/objectstorage/classes.connection.js +3 -2
  6. package/dist_ts/objectstorage/classes.requestdeadlineerror.d.ts +11 -0
  7. package/dist_ts/objectstorage/classes.requestdeadlineerror.js +16 -0
  8. package/dist_ts/objectstorage/classes.smartbucket.d.ts +11 -2
  9. package/dist_ts/objectstorage/classes.smartbucket.js +20 -1
  10. package/dist_ts/objectstorage/helpers.d.ts +4 -0
  11. package/dist_ts/objectstorage/helpers.js +13 -1
  12. package/dist_ts/objectstorage/index.d.ts +1 -0
  13. package/dist_ts/objectstorage/index.js +2 -1
  14. package/dist_ts/objectstorage/interfaces.d.ts +49 -0
  15. package/dist_ts/objectstorage/internal.exactupload.capability.js +4 -2
  16. package/dist_ts/objectstorage/internal.requestbounds.d.ts +37 -0
  17. package/dist_ts/objectstorage/internal.requestbounds.js +154 -0
  18. package/dist_ts/objectstorage/plugins.d.ts +2 -1
  19. package/dist_ts/objectstorage/plugins.js +3 -2
  20. package/package.json +2 -2
  21. package/readme.md +6 -0
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/objectstorage/classes.bucket.ts +80 -56
  24. package/ts/objectstorage/classes.connection.ts +4 -2
  25. package/ts/objectstorage/classes.requestdeadlineerror.ts +16 -0
  26. package/ts/objectstorage/classes.smartbucket.ts +30 -1
  27. package/ts/objectstorage/helpers.ts +16 -0
  28. package/ts/objectstorage/index.ts +1 -0
  29. package/ts/objectstorage/interfaces.ts +58 -0
  30. package/ts/objectstorage/internal.exactupload.capability.ts +3 -1
  31. package/ts/objectstorage/internal.requestbounds.ts +192 -0
  32. package/ts/objectstorage/plugins.ts +2 -1
@@ -0,0 +1,192 @@
1
+ import * as plugins from './plugins.js';
2
+ import { ObjectStorageRequestDeadlineError } from './classes.requestdeadlineerror.js';
3
+ import type {
4
+ IObjectStorageRequestBounds,
5
+ IObjectStorageRequestOptions,
6
+ } from './interfaces.js';
7
+
8
+ const requestOptionLimits = {
9
+ connectionTimeoutMs: { defaultValue: 10_000, maximum: 60_000 },
10
+ requestTimeoutMs: { defaultValue: 60_000, maximum: 3_600_000 },
11
+ maxAttempts: { defaultValue: 3, maximum: 10 },
12
+ } as const;
13
+
14
+ /**
15
+ * The longest wait of the SDK's standard retry strategy before a retry
16
+ * (`MAXIMUM_RETRY_DELAY` of `@smithy/core`, which caps a retry-after hint
17
+ * as well).
18
+ */
19
+ const maxRetryDelayMs = 20_000;
20
+
21
+ /**
22
+ * Time the deadline grants beyond the attempts and retry waits for the
23
+ * client's own work around them (serializing, signing, checksums), so an
24
+ * attempt's own timeout decides its failure and the deadline only ends a
25
+ * request the attempt bounds do not.
26
+ */
27
+ const requestDeadlineAllowanceMs = 1_000;
28
+
29
+ type TRequestOptionName = keyof typeof requestOptionLimits;
30
+
31
+ const requireRequestOption = (
32
+ optionsArg: IObjectStorageRequestOptions,
33
+ nameArg: TRequestOptionName,
34
+ ): number => {
35
+ const value: unknown = optionsArg[nameArg];
36
+ const { defaultValue, maximum } = requestOptionLimits[nameArg];
37
+ if (value === undefined) {
38
+ return defaultValue;
39
+ }
40
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > maximum) {
41
+ throw new TypeError(
42
+ `Object storage ${nameArg} must be a positive safe integer no greater than ${maximum}.`,
43
+ );
44
+ }
45
+ return value;
46
+ };
47
+
48
+ /**
49
+ * Validates the request options of a storage client and derives its bounds.
50
+ */
51
+ export const resolveRequestBounds = (
52
+ optionsArg: IObjectStorageRequestOptions,
53
+ ): IObjectStorageRequestBounds => {
54
+ const connectionTimeoutMs = requireRequestOption(optionsArg, 'connectionTimeoutMs');
55
+ const requestTimeoutMs = requireRequestOption(optionsArg, 'requestTimeoutMs');
56
+ const maxAttempts = requireRequestOption(optionsArg, 'maxAttempts');
57
+ return Object.freeze({
58
+ connectionTimeoutMs,
59
+ requestTimeoutMs,
60
+ maxAttempts,
61
+ maxRetryDelayMs,
62
+ requestDeadlineMs: maxAttempts * requestTimeoutMs
63
+ + (maxAttempts - 1) * maxRetryDelayMs
64
+ + requestDeadlineAllowanceMs,
65
+ });
66
+ };
67
+
68
+ /**
69
+ * The S3 client configuration that applies the bounds to every attempt:
70
+ * `@smithy/node-http-handler` destroys an attempt whose socket does not
71
+ * connect within `connectionTimeout` and, with `throwOnRequestTimeout`, one
72
+ * whose response headers did not arrive within `requestTimeout` of its start.
73
+ * `maxAttempts` and the standard retry mode are set explicitly, so
74
+ * `AWS_MAX_ATTEMPTS`, `AWS_RETRY_MODE` and shared config files cannot change
75
+ * them.
76
+ */
77
+ export const createBoundedClientConfig = (boundsArg: IObjectStorageRequestBounds) => ({
78
+ maxAttempts: boundsArg.maxAttempts,
79
+ retryMode: 'standard',
80
+ requestHandler: {
81
+ connectionTimeout: boundsArg.connectionTimeoutMs,
82
+ requestTimeout: boundsArg.requestTimeoutMs,
83
+ throwOnRequestTimeout: true,
84
+ },
85
+ }) satisfies Partial<plugins.s3.S3ClientConfig>;
86
+
87
+ type TSendOptions = { abortSignal?: AbortSignal } & Record<string, unknown>;
88
+ type TSendCallback = (errorArg: unknown, outputArg?: unknown) => void;
89
+ type TUnboundedSend = (commandArg: unknown, optionsArg?: TSendOptions) => Promise<unknown>;
90
+
91
+ const readStreamingBody = (outputArg: unknown): plugins.stream.Readable | undefined => {
92
+ if (typeof outputArg !== 'object' || outputArg === null) {
93
+ return undefined;
94
+ }
95
+ const body = (outputArg as { Body?: unknown }).Body;
96
+ return body instanceof plugins.stream.Readable ? body : undefined;
97
+ };
98
+
99
+ /**
100
+ * Bounds every `send()` of a storage client by `requestDeadlineMs`.
101
+ *
102
+ * Each request gets its own abort signal, which the caller's `abortSignal`
103
+ * and the deadline abort. The HTTP handler destroys the attempt in flight
104
+ * when it aborts, and refuses every later attempt of the request, so no
105
+ * attempt starts or sends after the deadline. The request rejects at the
106
+ * deadline with `ObjectStorageRequestDeadlineError`; the handler fails the
107
+ * aborted attempt with an `AbortError`, which the retry strategy never
108
+ * retries. A caller's abort still reaches a response body the request
109
+ * resolved with until that body closes.
110
+ */
111
+ export const bindRequestDeadline = (
112
+ clientArg: plugins.s3.S3Client,
113
+ boundsArg: IObjectStorageRequestBounds,
114
+ ): void => {
115
+ const sendWithoutDeadline = clientArg.send.bind(clientArg) as unknown as TUnboundedSend;
116
+ const sendWithinDeadline = (
117
+ commandArg: unknown,
118
+ optionsArg: TSendOptions | undefined,
119
+ ): Promise<unknown> => new Promise((resolve, reject) => {
120
+ const callerSignal = optionsArg?.abortSignal;
121
+ const requestAbortController = new AbortController();
122
+ const forwardCallerAbort = (): void => {
123
+ requestAbortController.abort(callerSignal?.reason);
124
+ };
125
+ const releaseCallerSignal = (): void => {
126
+ callerSignal?.removeEventListener('abort', forwardCallerAbort);
127
+ };
128
+ if (callerSignal?.aborted) {
129
+ forwardCallerAbort();
130
+ } else {
131
+ callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true });
132
+ }
133
+ let settled = false;
134
+ const deadlineTimer = setTimeout(() => {
135
+ settled = true;
136
+ const deadlineError = new ObjectStorageRequestDeadlineError(boundsArg.requestDeadlineMs);
137
+ requestAbortController.abort(deadlineError);
138
+ reject(deadlineError);
139
+ }, boundsArg.requestDeadlineMs);
140
+ sendWithoutDeadline(commandArg, {
141
+ ...optionsArg,
142
+ abortSignal: requestAbortController.signal,
143
+ }).then(
144
+ (outputArg) => {
145
+ clearTimeout(deadlineTimer);
146
+ const body = readStreamingBody(outputArg);
147
+ if (settled) {
148
+ releaseCallerSignal();
149
+ body?.destroy();
150
+ return;
151
+ }
152
+ settled = true;
153
+ if (body && !body.destroyed) {
154
+ body.once('close', releaseCallerSignal);
155
+ } else {
156
+ releaseCallerSignal();
157
+ }
158
+ resolve(outputArg);
159
+ },
160
+ (errorArg: unknown) => {
161
+ clearTimeout(deadlineTimer);
162
+ releaseCallerSignal();
163
+ if (settled) {
164
+ return;
165
+ }
166
+ settled = true;
167
+ reject(errorArg);
168
+ },
169
+ );
170
+ });
171
+ const boundedSend = (
172
+ commandArg: unknown,
173
+ optionsOrCallbackArg?: TSendOptions | TSendCallback,
174
+ callbackArg?: TSendCallback,
175
+ ): Promise<unknown> | void => {
176
+ const callback = typeof optionsOrCallbackArg === 'function' ? optionsOrCallbackArg : callbackArg;
177
+ const options = typeof optionsOrCallbackArg === 'function' ? undefined : optionsOrCallbackArg;
178
+ const result = sendWithinDeadline(commandArg, options);
179
+ if (typeof callback !== 'function') {
180
+ return result;
181
+ }
182
+ // As the SDK's own callback form: a callback that throws must not become
183
+ // an unhandled rejection.
184
+ result.then(
185
+ (outputArg) => callback(null, outputArg),
186
+ (errorArg: unknown) => callback(errorArg),
187
+ ).catch(() => {});
188
+ };
189
+ // S3Client.send() is generic over the SDK's Command type, which the
190
+ // optional peer does not export; the bounded send keeps its signature.
191
+ clientArg.send = boundedSend as typeof clientArg.send;
192
+ };
@@ -4,8 +4,9 @@
4
4
  import * as crypto from 'node:crypto';
5
5
  import * as path from 'node:path';
6
6
  import * as stream from 'node:stream';
7
+ import * as nodeUtil from 'node:util';
7
8
 
8
- export { crypto, path, stream };
9
+ export { crypto, nodeUtil, path, stream };
9
10
 
10
11
  // @push.rocks scope
11
12
  import * as smartmime from '@push.rocks/smartmime';