create-monolayer-app 0.2.2 → 0.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-monolayer-app",
3
- "version": "0.2.2",
4
- "description": "CLI to scaffold a new Monolayer application",
3
+ "version": "0.3.0",
4
+ "description": "CLI to scaffold a new monolayer application",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-monolayer-app": "./bin/index.js"
@@ -0,0 +1,63 @@
1
+ import { FileBucket } from "../../vendor/file-bucket";
2
+ import { parseFileBucketRequest } from "../../vendor/file-bucket/protocol";
3
+ import {
4
+ dispatch,
5
+ MAX_BATCH_KEYS,
6
+ MAX_REQUEST_BYTES,
7
+ readLimitedJson,
8
+ type FileBucketRequest,
9
+ } from "../../vendor/file-bucket/react-router";
10
+
11
+ export const action = async ({
12
+ request,
13
+ }: {
14
+ request: Request;
15
+ }): Promise<Response> => {
16
+ if (request.method !== "POST") {
17
+ return new Response("Method not allowed", {
18
+ status: 405,
19
+ headers: { Allow: "POST" },
20
+ });
21
+ }
22
+
23
+ const contentTypeHeader = request.headers.get("content-type");
24
+ const mediaType = contentTypeHeader?.toLowerCase().split(";")[0]?.trim();
25
+ if (mediaType !== "application/json") {
26
+ return new Response("Unsupported Media Type", { status: 415 });
27
+ }
28
+
29
+ const contentLengthHeader = request.headers.get("content-length");
30
+ if (contentLengthHeader !== null) {
31
+ const declaredLength = Number(contentLengthHeader);
32
+ if (!Number.isNaN(declaredLength) && declaredLength > MAX_REQUEST_BYTES) {
33
+ return new Response("Request too large", { status: 413 });
34
+ }
35
+ }
36
+
37
+ let value: unknown;
38
+ try {
39
+ value = await readLimitedJson(request, MAX_REQUEST_BYTES);
40
+ } catch (error) {
41
+ if (error instanceof Response) return error;
42
+ return new Response("Invalid JSON", { status: 400 });
43
+ }
44
+
45
+ let input: Readonly<FileBucketRequest>;
46
+ try {
47
+ input = parseFileBucketRequest(value, { maxBatchKeys: MAX_BATCH_KEYS });
48
+ } catch {
49
+ return new Response("Invalid file request", { status: 400 });
50
+ }
51
+
52
+ const bucket = new FileBucket(input.bucketId);
53
+ const result = await dispatch(bucket, input);
54
+ if (result === undefined) {
55
+ return new Response(null, {
56
+ status: 204,
57
+ headers: { "Cache-Control": "no-store" },
58
+ });
59
+ }
60
+ return Response.json(result, {
61
+ headers: { "Cache-Control": "private, no-store" },
62
+ });
63
+ };
@@ -1,3 +1,6 @@
1
- import { type RouteConfig, index } from "@react-router/dev/routes";
1
+ import { type RouteConfig, index, route } from "@react-router/dev/routes";
2
2
 
3
- export default [index("routes/home.tsx")] satisfies RouteConfig;
3
+ export default [
4
+ index("routes/home.tsx"),
5
+ route("resources/files", "routes/files.ts"),
6
+ ] satisfies RouteConfig;
@@ -3,5 +3,10 @@ import ultracite from "ultracite/oxfmt";
3
3
 
4
4
  export default defineConfig({
5
5
  ...ultracite,
6
- ignorePatterns: [".agents/**"],
6
+ ignorePatterns: [
7
+ ".agents/**",
8
+ ".emulator/**",
9
+ "vendor/file-bucket/vite.js",
10
+ "vendor/file-bucket/vite.d.ts",
11
+ ],
7
12
  });
@@ -1,16 +1,12 @@
1
1
  import { defineConfig } from "oxlint";
2
2
  import antiSlop from "ultracite/oxlint/anti-slop";
3
3
  import core from "ultracite/oxlint/core";
4
- import { jsPluginSettings, selectJsPlugins } from "ultracite/oxlint/js-plugins";
5
4
  import react from "ultracite/oxlint/react";
6
5
  import remix from "ultracite/oxlint/remix";
7
6
 
8
- const jsPlugins = selectJsPlugins(["react-doctor"]);
9
-
10
7
  export default defineConfig({
11
- extends: [core, react, remix, antiSlop, jsPlugins],
12
- ignorePatterns: core.ignorePatterns,
13
- jsPlugins: jsPlugins.jsPlugins,
8
+ extends: [core, react, remix, antiSlop],
9
+ ignorePatterns: [...(core.ignorePatterns ?? []), "vendor/file-bucket/**"],
14
10
  rules: {
15
11
  "react/function-component-definition": [
16
12
  "error",
@@ -28,8 +24,8 @@ export default defineConfig({
28
24
  ],
29
25
  "no-use-before-define": "off",
30
26
  "no-empty-pattern": "off",
31
- "react-doctor/rendering-svg-precision": "off",
32
27
  "arrow-body-style": "off",
28
+ curly: "off",
33
29
  },
34
30
  overrides: [
35
31
  {
@@ -41,5 +37,4 @@ export default defineConfig({
41
37
  },
42
38
  },
43
39
  ],
44
- settings: jsPluginSettings,
45
40
  });
@@ -0,0 +1,196 @@
1
+ import type { FileBucket } from "./index.js";
2
+ import { createMultipartUploadClient } from "./multipart.js";
3
+ import { uploadSignedUrl } from "./transfers.js";
4
+ import { request } from "./transport.js";
5
+ import type { GetUrlOptions, PutOptions, PutUrlOptions } from "./types.js";
6
+
7
+ export { createMultipartUploadClient } from "./multipart.js";
8
+ export type { UploadGrant } from "./protocol.js";
9
+ export type {
10
+ FileMultipartUploadDescriptor,
11
+ FileMultipartUploadClient,
12
+ UploadOptions,
13
+ UploadProgress,
14
+ GetUrlOptions,
15
+ PutOptions,
16
+ PutUrlOptions,
17
+ } from "./types.js";
18
+ export type BrowserUploadBody =
19
+ | Blob
20
+ | string
21
+ | Uint8Array<ArrayBuffer>
22
+ | ArrayBuffer;
23
+
24
+ /**
25
+ * Delete a single object from storage via the signing resource endpoint.
26
+ *
27
+ * Deleting a missing key succeeds. Cancellation stops waiting but cannot undo
28
+ * server-side deletion. Endpoint failures reject the promise.
29
+ *
30
+ * @param bucket - The `FileBucket` target definition.
31
+ * @param path - Key of the object to delete.
32
+ * @param options - Optional settings including an `AbortSignal`.
33
+ * @returns A promise that resolves when the deletion is completed by the server.
34
+ */
35
+ function deleteFile(
36
+ bucket: FileBucket,
37
+ path: string,
38
+ options: { signal?: AbortSignal } = {}
39
+ ): Promise<void> {
40
+ return request(
41
+ { operation: "delete", bucketId: bucket.name, path },
42
+ options.signal
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Delete multiple objects from storage in batch via the signing resource endpoint.
48
+ *
49
+ * An empty list sends no request unless cancellation has already been requested,
50
+ * in which case the promise rejects. Batches are not atomic: some keys may be
51
+ * deleted before an error. Cancellation cannot undo completed deletions.
52
+ *
53
+ * @param bucket - The `FileBucket` target definition.
54
+ * @param paths - Array of object keys to delete.
55
+ * @param options - Optional settings including an `AbortSignal`.
56
+ * @returns A promise that resolves when batch deletion is completed by the server.
57
+ */
58
+ export async function deleteBatch(
59
+ bucket: FileBucket,
60
+ paths: string[],
61
+ options: { signal?: AbortSignal } = {}
62
+ ): Promise<void> {
63
+ options.signal?.throwIfAborted();
64
+ if (!paths.length) return;
65
+ await request(
66
+ { operation: "deleteBatch", bucketId: bucket.name, paths },
67
+ options.signal
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Request a presigned GET URL for an object from the server without initiating a download.
73
+ *
74
+ * Signing does not check object existence. Content disposition is unchanged by
75
+ * default; URL lifetime defaults to 3600 seconds. Cancellation applies to signing.
76
+ *
77
+ * @param bucket - The `FileBucket` target definition.
78
+ * @param path - Key of the object to access.
79
+ * @param options - Optional URL configuration such as `expiresIn`, `contentDisposition`, and `signal`.
80
+ * @returns A promise resolving to the presigned GET URL string.
81
+ */
82
+ export async function getUrl(
83
+ bucket: FileBucket,
84
+ path: string,
85
+ options: GetUrlOptions & { signal?: AbortSignal } = {}
86
+ ): Promise<string> {
87
+ const { signal, ...signingOptions } = options;
88
+ const result = await request(
89
+ { ...signingOptions, operation: "getUrl", bucketId: bucket.name, path },
90
+ signal
91
+ );
92
+ return result.url;
93
+ }
94
+
95
+ /**
96
+ * Request a presigned single-PUT upload URL from the server without sending file bytes.
97
+ *
98
+ * Send the raw body with the same content-type and cache-control headers used
99
+ * when signing. Metadata is carried in the signed query. No multipart session
100
+ * is created. URL lifetime defaults to 3600 seconds; cancellation applies to signing.
101
+ *
102
+ * @param bucket - The `FileBucket` target definition.
103
+ * @param path - Target key for the uploaded object.
104
+ * @param options - Optional URL configuration such as `expiresIn`, `contentType`, `metadata`, `cacheControl`, and `signal`.
105
+ * @returns A promise resolving to the presigned PUT URL string.
106
+ */
107
+ export async function putUrl(
108
+ bucket: FileBucket,
109
+ path: string,
110
+ options: PutUrlOptions & { signal?: AbortSignal } = {}
111
+ ): Promise<string> {
112
+ const { signal, ...signingOptions } = options;
113
+ const result = await request(
114
+ { ...signingOptions, operation: "putUrl", bucketId: bucket.name, path },
115
+ signal
116
+ );
117
+ return result.url;
118
+ }
119
+
120
+ /**
121
+ * Request an attachment download URL from the server and trigger native browser download navigation.
122
+ *
123
+ * Resolves as soon as the browser initiates the download link; does not buffer file bytes in memory.
124
+ * It cannot confirm saving or report subsequent storage errors. Cancellation
125
+ * applies before handoff and cannot stop the native download afterward.
126
+ *
127
+ * @param bucket - The `FileBucket` target definition.
128
+ * @param path - Key of the object to download.
129
+ * @param options - Optional settings including an `AbortSignal`.
130
+ * @returns A promise that resolves once the download has been triggered in the browser.
131
+ */
132
+ export async function get(
133
+ bucket: FileBucket,
134
+ path: string,
135
+ options: { signal?: AbortSignal } = {}
136
+ ): Promise<void> {
137
+ const grant = await request(
138
+ { operation: "get", bucketId: bucket.name, path },
139
+ options.signal
140
+ );
141
+ const link = document.createElement("a");
142
+ link.href = grant.url;
143
+ link.download = "";
144
+ link.hidden = true;
145
+ document.body.append(link);
146
+ try {
147
+ link.click();
148
+ } finally {
149
+ link.remove();
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Upload a file or data directly to S3 from the browser using a server-issued grant.
155
+ *
156
+ * Uses `/resources/files` for signing, then sends bytes directly to storage.
157
+ * The standard resource action selects a single PUT below 8 MiB and multipart
158
+ * at or above 8 MiB. Reusing a key overwrites its object.
159
+ *
160
+ * Single PUT reports progress after completion; multipart reports completed part
161
+ * bytes and attempts cleanup on cancellation or failure. A Blob's content type
162
+ * is used unless overridden. Endpoint or storage failures reject the promise.
163
+ *
164
+ * @param bucket - The `FileBucket` target definition.
165
+ * @param path - Key of the destination object.
166
+ * @param body - The payload to upload (`Blob`, `File`, `string`, `Uint8Array`, or `ArrayBuffer`).
167
+ * @param options - Optional upload settings including `contentType`, `metadata`, `cacheControl`, `onProgress`, and `signal`.
168
+ * @returns A promise that resolves when the upload is confirmed complete.
169
+ */
170
+ export async function put(
171
+ bucket: FileBucket,
172
+ path: string,
173
+ body: BrowserUploadBody,
174
+ options: PutOptions = {}
175
+ ): Promise<void> {
176
+ options.signal?.throwIfAborted();
177
+ const file = body instanceof Blob ? body : new Blob([body]);
178
+ const grant = await request(
179
+ {
180
+ operation: "put",
181
+ bucketId: bucket.name,
182
+ path,
183
+ size: file.size,
184
+ contentType: options.contentType ?? (file.type || undefined),
185
+ metadata: options.metadata,
186
+ cacheControl: options.cacheControl,
187
+ },
188
+ options.signal
189
+ );
190
+ if (grant.type === "multipart") {
191
+ await createMultipartUploadClient(grant.descriptor).upload(file, options);
192
+ return;
193
+ }
194
+ await uploadSignedUrl(grant.url, file, grant.headers, options);
195
+ }
196
+ export { deleteFile as delete };
@@ -0,0 +1,26 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Modified
4
+
5
+ /**
6
+ * Error names returned by FileBucket actions.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * try {
11
+ * await put(bucket, 'report.txt', body);
12
+ * } catch (e: unknown) {
13
+ * if (e instanceof Error && e.name === FileBucketErrors.FileTooLarge) {
14
+ * // handle an oversized file
15
+ * }
16
+ * throw e;
17
+ * }
18
+ * ```
19
+ */
20
+ export const FileBucketErrors = {
21
+ FileNotFound: "NoSuchKey",
22
+ FileTooLarge: "EntityTooLarge",
23
+ UploadExpired: "UploadExpired",
24
+ UploadCompletionUnknown: "UploadCompletionUnknown",
25
+ InvalidUploadState: "InvalidUploadState",
26
+ } as const;
@@ -0,0 +1,7 @@
1
+ /** Shared file bucket definition. Safe to import on the server and in the browser. */
2
+ export class FileBucket {
3
+ readonly name: string;
4
+ constructor(name: string) {
5
+ this.name = name;
6
+ }
7
+ }
@@ -0,0 +1,304 @@
1
+ import type {
2
+ FileMultipartUploadClient,
3
+ FileMultipartUploadDescriptor,
4
+ UploadOptions,
5
+ } from "./types.js";
6
+ // Parts and retries deliberately run in bounded, sequential worker loops.
7
+ /* oxlint-disable no-await-in-loop */
8
+ import { escapeXml, parseXml } from "./xml.js";
9
+
10
+ export type {
11
+ FileMultipartUploadClient,
12
+ FileMultipartUploadDescriptor,
13
+ UploadOptions,
14
+ UploadProgress,
15
+ } from "./types.js";
16
+
17
+ function uploadError(name: string, message: string, cause?: unknown): Error {
18
+ return Object.assign(new Error(message, { cause }), { name });
19
+ }
20
+
21
+ function delay(ms: number, signal: AbortSignal): Promise<void> {
22
+ signal.throwIfAborted();
23
+ // oxlint-disable-next-line promise/avoid-new
24
+ return new Promise((resolve, reject) => {
25
+ const abort = () => {
26
+ clearTimeout(timer);
27
+ reject(signal.reason);
28
+ };
29
+ const timer = setTimeout(() => {
30
+ signal.removeEventListener("abort", abort);
31
+ resolve();
32
+ }, ms);
33
+ signal.addEventListener("abort", abort, { once: true });
34
+ });
35
+ }
36
+
37
+ async function responseError(response: Response): Promise<Error> {
38
+ let code = "UploadFailed";
39
+ try {
40
+ const error = parseXml(await response.text());
41
+ code = error.findText("Code") || code;
42
+ if (
43
+ code === "AccessDenied" &&
44
+ /expired/iu.test(error.findText("Message") || "")
45
+ )
46
+ code = "UploadExpired";
47
+ } catch {
48
+ // HTTP status remains available when a proxy returns a non-XML error.
49
+ }
50
+ if (code === "RequestExpired" || code === "ExpiredToken")
51
+ code = "UploadExpired";
52
+ return uploadError(
53
+ code,
54
+ `S3 upload request failed (${response.status}, ${code}).`
55
+ );
56
+ }
57
+
58
+ /** Hydrate the wire descriptor without Node.js or AWS SDK runtime dependencies. */
59
+ export function createMultipartUploadClient(
60
+ descriptor: FileMultipartUploadDescriptor
61
+ ): FileMultipartUploadClient {
62
+ const controller = new AbortController();
63
+ let state:
64
+ | "ready"
65
+ | "uploading"
66
+ | "completed"
67
+ | "aborted"
68
+ | "failed"
69
+ | "unknown" = "ready";
70
+ let active: Promise<void> | undefined;
71
+ let cleaned = false;
72
+ let cleaning: Promise<void> | undefined;
73
+
74
+ function checkExpiry(): void {
75
+ if (Date.now() >= descriptor.expiresAt) {
76
+ throw uploadError(
77
+ "UploadExpired",
78
+ "The multipart upload URLs have expired. Create a new handle."
79
+ );
80
+ }
81
+ }
82
+
83
+ async function sendPart(
84
+ url: string,
85
+ body: Blob,
86
+ signal: AbortSignal
87
+ ): Promise<string> {
88
+ for (let attempt = 0; attempt < 3; attempt += 1) {
89
+ signal.throwIfAborted();
90
+ checkExpiry();
91
+ let response: Response;
92
+ try {
93
+ response = await fetch(url, {
94
+ method: "PUT",
95
+ body,
96
+ signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]),
97
+ });
98
+ } catch (error) {
99
+ signal.throwIfAborted();
100
+ if (attempt === 2) throw error;
101
+ await delay(200 * 2 ** attempt, signal);
102
+ continue;
103
+ }
104
+ if (response.ok) {
105
+ const etag = response.headers.get("etag");
106
+ await response.arrayBuffer();
107
+ if (!etag)
108
+ throw uploadError(
109
+ "MissingETag",
110
+ "S3 did not expose the part ETag. Check the bucket CORS configuration."
111
+ );
112
+ return etag;
113
+ }
114
+ const error = await responseError(response);
115
+ if (attempt === 2 || (response.status !== 429 && response.status < 500))
116
+ throw error;
117
+ await delay(200 * 2 ** attempt, signal);
118
+ }
119
+ throw new Error("Part upload retry limit reached.");
120
+ }
121
+
122
+ async function cleanUp(): Promise<void> {
123
+ if (cleaned) return;
124
+ if (cleaning) return cleaning;
125
+ cleaning = (async () => {
126
+ const response = await fetch(descriptor.abortUrl, {
127
+ method: "DELETE",
128
+ signal: AbortSignal.timeout(15_000),
129
+ });
130
+ if (!response.ok && response.status !== 404)
131
+ throw await responseError(response);
132
+ await response.arrayBuffer();
133
+ cleaned = true;
134
+ })();
135
+ try {
136
+ await cleaning;
137
+ } finally {
138
+ cleaning = undefined;
139
+ }
140
+ }
141
+
142
+ async function execute(
143
+ body: Blob | File | ArrayBuffer | Uint8Array,
144
+ options: UploadOptions
145
+ ): Promise<void> {
146
+ const signal = options.signal
147
+ ? AbortSignal.any([controller.signal, options.signal])
148
+ : controller.signal;
149
+ try {
150
+ signal.throwIfAborted();
151
+ checkExpiry();
152
+ const file =
153
+ body instanceof Blob
154
+ ? body
155
+ : new Blob([
156
+ body instanceof Uint8Array ? new Uint8Array(body).buffer : body,
157
+ ]);
158
+ if (file.size !== descriptor.size)
159
+ throw new RangeError(
160
+ "File size does not match the multipart upload handle."
161
+ );
162
+ let nextPart = 0;
163
+ let uploadedBytes = 0;
164
+ const etags = Array.from(
165
+ { length: descriptor.partUrls.length },
166
+ () => ""
167
+ );
168
+ const worker = async () => {
169
+ try {
170
+ while (nextPart < descriptor.partUrls.length) {
171
+ signal.throwIfAborted();
172
+ const index = nextPart;
173
+ nextPart += 1;
174
+ const start = index * descriptor.partSize;
175
+ const part = file.slice(
176
+ start,
177
+ Math.min(file.size, start + descriptor.partSize)
178
+ );
179
+ etags[index] = await sendPart(
180
+ descriptor.partUrls[index],
181
+ part,
182
+ signal
183
+ );
184
+ uploadedBytes += part.size;
185
+ options.onProgress?.({
186
+ uploadedBytes,
187
+ totalBytes: descriptor.size,
188
+ });
189
+ }
190
+ } catch (error) {
191
+ controller.abort(error);
192
+ throw error;
193
+ }
194
+ };
195
+ const workers = await Promise.allSettled(
196
+ Array.from({ length: Math.min(4, etags.length) }, worker)
197
+ );
198
+ const failure = workers.find((result) => result.status === "rejected");
199
+ if (failure?.status === "rejected") throw failure.reason;
200
+ signal.throwIfAborted();
201
+ checkExpiry();
202
+ const xml = `<CompleteMultipartUpload>${etags.map((etag, index) => `<Part><PartNumber>${index + 1}</PartNumber><ETag>${escapeXml(etag)}</ETag></Part>`).join("")}</CompleteMultipartUpload>`;
203
+ // Once completion is sent, a lost response cannot prove whether S3 committed.
204
+ let response: Response;
205
+ let result;
206
+ try {
207
+ response = await fetch(descriptor.completeUrl, {
208
+ method: "POST",
209
+ body: xml,
210
+ headers: { "Content-Type": "application/xml" },
211
+ signal: AbortSignal.any([signal, AbortSignal.timeout(300_000)]),
212
+ });
213
+ result = parseXml(await response.text());
214
+ } catch (error) {
215
+ state = "unknown";
216
+ throw uploadError(
217
+ "UploadCompletionUnknown",
218
+ "The completion result is unknown. Check the object before retrying.",
219
+ error
220
+ );
221
+ }
222
+ if (result.localName() === "Error") {
223
+ throw uploadError(
224
+ result.findText("Code") || "UploadFailed",
225
+ result.findText("Message") || "S3 rejected multipart completion."
226
+ );
227
+ }
228
+ if (
229
+ !response.ok ||
230
+ result.localName() !== "CompleteMultipartUploadResult" ||
231
+ !result.findText("ETag")
232
+ ) {
233
+ state = "unknown";
234
+ throw uploadError(
235
+ "UploadCompletionUnknown",
236
+ "S3 did not return a valid completion result. Check the object before retrying."
237
+ );
238
+ }
239
+ state = "completed";
240
+ } catch (error) {
241
+ controller.abort(error);
242
+ if (state !== "unknown") {
243
+ state = signal.aborted ? "aborted" : "failed";
244
+ try {
245
+ await cleanUp();
246
+ } catch (cleanupError) {
247
+ throw uploadError(
248
+ error instanceof Error ? error.name : "UploadFailed",
249
+ "Upload failed and its session could not be cleaned up.",
250
+ new AggregateError(
251
+ [error, cleanupError],
252
+ "Upload and cleanup failed."
253
+ )
254
+ );
255
+ }
256
+ }
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ const isCompleted = () => state === "completed";
262
+ return {
263
+ async upload(body, options = {}) {
264
+ if (state !== "ready")
265
+ throw uploadError(
266
+ "InvalidUploadState",
267
+ "This multipart upload handle has already been used."
268
+ );
269
+ state = "uploading";
270
+ active = execute(body, options);
271
+ await active;
272
+ },
273
+ async abort() {
274
+ if (isCompleted()) return;
275
+ controller.abort(new DOMException("Upload aborted.", "AbortError"));
276
+ if (active) {
277
+ try {
278
+ await active;
279
+ } catch {
280
+ /* The upload caller receives the original error. */
281
+ }
282
+ }
283
+ if (isCompleted()) return;
284
+ await cleanUp();
285
+ state = "aborted";
286
+ },
287
+ toJSON: () => descriptor,
288
+ };
289
+ }
290
+
291
+ const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
292
+ const MAX_PARTS = 10_000;
293
+ const MAX_PART_SIZE = 5 * 1024 * 1024 * 1024;
294
+
295
+ export function multipartPartSize(size?: number): number {
296
+ if (size === undefined) return DEFAULT_PART_SIZE;
297
+ if (!Number.isSafeInteger(size) || size < 0) {
298
+ throw new RangeError("Upload size must be a nonnegative safe integer.");
299
+ }
300
+ const partSize = Math.max(DEFAULT_PART_SIZE, Math.ceil(size / MAX_PARTS));
301
+ if (partSize > MAX_PART_SIZE)
302
+ throw new RangeError("Upload exceeds the multipart size limit.");
303
+ return partSize;
304
+ }