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 +2 -2
- package/template/app/routes/files.ts +63 -0
- package/template/app/routes.ts +5 -2
- package/template/oxfmt.config.ts +6 -1
- package/template/oxlint.config.ts +3 -8
- package/template/vendor/file-bucket/client.ts +196 -0
- package/template/vendor/file-bucket/errors.ts +26 -0
- package/template/vendor/file-bucket/index.ts +7 -0
- package/template/vendor/file-bucket/multipart.ts +304 -0
- package/template/vendor/file-bucket/protocol.ts +222 -0
- package/template/vendor/file-bucket/react-router.ts +222 -0
- package/template/vendor/file-bucket/server.ts +544 -0
- package/template/vendor/file-bucket/transfers.ts +23 -0
- package/template/vendor/file-bucket/transport.ts +37 -0
- package/template/vendor/file-bucket/types.ts +153 -0
- package/template/vendor/file-bucket/vite.d.ts +7 -0
- package/template/vendor/file-bucket/vite.js +2482 -0
- package/template/vendor/file-bucket/xml.ts +325 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-monolayer-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI to scaffold a new
|
|
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
|
+
};
|
package/template/app/routes.ts
CHANGED
|
@@ -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 [
|
|
3
|
+
export default [
|
|
4
|
+
index("routes/home.tsx"),
|
|
5
|
+
route("resources/files", "routes/files.ts"),
|
|
6
|
+
] satisfies RouteConfig;
|
package/template/oxfmt.config.ts
CHANGED
|
@@ -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
|
|
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,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
|
+
}
|