create-monolayer-app 0.2.2 → 0.4.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/_gitignore +9 -4
- 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/package.json +2 -1
- 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/template/vendor/package-app/package-app.mjs +116 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Validate JSON at the client/server boundary before using it as a protocol value.
|
|
2
|
+
/* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-parameters */
|
|
3
|
+
import type {
|
|
4
|
+
FileMultipartUploadDescriptor,
|
|
5
|
+
GetUrlOptions,
|
|
6
|
+
PutOptions,
|
|
7
|
+
PutUrlOptions,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
type UploadMetadata = Pick<
|
|
11
|
+
PutOptions,
|
|
12
|
+
"contentType" | "metadata" | "cacheControl"
|
|
13
|
+
>;
|
|
14
|
+
export type FileBucketRequest = { bucketId: string } & (
|
|
15
|
+
| ({ operation: "put"; path: string; size: number } & UploadMetadata)
|
|
16
|
+
| { operation: "get"; path: string }
|
|
17
|
+
| ({ operation: "getUrl"; path: string } & GetUrlOptions)
|
|
18
|
+
| ({ operation: "putUrl"; path: string } & PutUrlOptions)
|
|
19
|
+
| { operation: "delete"; path: string }
|
|
20
|
+
| { operation: "deleteBatch"; paths: string[] }
|
|
21
|
+
);
|
|
22
|
+
export type FileBucketOperation = FileBucketRequest["operation"];
|
|
23
|
+
export type UploadGrant =
|
|
24
|
+
| { type: "single"; url: string; headers: Record<string, string> }
|
|
25
|
+
| { type: "multipart"; descriptor: FileMultipartUploadDescriptor };
|
|
26
|
+
export interface SignedUrl {
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
export interface FileBucketResponses {
|
|
30
|
+
put: UploadGrant;
|
|
31
|
+
get: SignedUrl;
|
|
32
|
+
getUrl: SignedUrl;
|
|
33
|
+
putUrl: SignedUrl;
|
|
34
|
+
delete: undefined;
|
|
35
|
+
deleteBatch: undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParseRequestOptions {
|
|
39
|
+
maxBatchKeys?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_KEY_BYTES = 1024;
|
|
43
|
+
const MAX_METADATA_ENTRIES = 50;
|
|
44
|
+
const MAX_METADATA_KEY_LENGTH = 128;
|
|
45
|
+
const MAX_METADATA_VALUE_LENGTH = 2048;
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
function textByteLength(value: string): number {
|
|
51
|
+
return new TextEncoder().encode(value).length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isKey(value: unknown): value is string {
|
|
55
|
+
return (
|
|
56
|
+
typeof value === "string" &&
|
|
57
|
+
value.length > 0 &&
|
|
58
|
+
textByteLength(value) <= MAX_KEY_BYTES
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
function isText(value: unknown): value is string {
|
|
62
|
+
return typeof value === "string" && value.length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isOptionalText(
|
|
66
|
+
value: unknown,
|
|
67
|
+
maxLen = 1024
|
|
68
|
+
): value is string | undefined {
|
|
69
|
+
return (
|
|
70
|
+
value === undefined ||
|
|
71
|
+
(typeof value === "string" && textByteLength(value) <= maxLen)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
75
|
+
if (!isRecord(value)) return false;
|
|
76
|
+
const entries = Object.entries(value);
|
|
77
|
+
if (entries.length > MAX_METADATA_ENTRIES) return false;
|
|
78
|
+
return entries.every(
|
|
79
|
+
([k, v]) =>
|
|
80
|
+
typeof v === "string" &&
|
|
81
|
+
k.length <= MAX_METADATA_KEY_LENGTH &&
|
|
82
|
+
v.length <= MAX_METADATA_VALUE_LENGTH
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
function isSize(value: unknown): value is number {
|
|
86
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
87
|
+
}
|
|
88
|
+
function isMetadata(input: Record<string, unknown>): boolean {
|
|
89
|
+
return (
|
|
90
|
+
isOptionalText(input.contentType, 512) &&
|
|
91
|
+
isOptionalText(input.cacheControl, 512) &&
|
|
92
|
+
(input.metadata === undefined || isStringRecord(input.metadata))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
function isSigningOptions(input: Record<string, unknown>): boolean {
|
|
96
|
+
return (
|
|
97
|
+
isMetadata(input) &&
|
|
98
|
+
isOptionalText(input.contentDisposition, 512) &&
|
|
99
|
+
(input.expiresIn === undefined ||
|
|
100
|
+
(isSize(input.expiresIn) &&
|
|
101
|
+
input.expiresIn >= 1 &&
|
|
102
|
+
input.expiresIn <= 604_800))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function deepFreeze<T>(object: T): Readonly<T> {
|
|
107
|
+
if (object === null || typeof object !== "object") return object;
|
|
108
|
+
Object.freeze(object);
|
|
109
|
+
for (const key of Object.keys(object)) {
|
|
110
|
+
// SAFETY: The object has been verified as a non-null object.
|
|
111
|
+
const val = (object as Record<string, unknown>)[key];
|
|
112
|
+
if (val !== null && typeof val === "object" && !Object.isFrozen(val)) {
|
|
113
|
+
deepFreeze(val);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return object;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isRequest(
|
|
120
|
+
input: Record<string, unknown>,
|
|
121
|
+
maxBatchKeys = 1000
|
|
122
|
+
): input is Record<string, unknown> & FileBucketRequest {
|
|
123
|
+
if (!isKey(input.bucketId)) return false;
|
|
124
|
+
if (input.operation === "deleteBatch") {
|
|
125
|
+
return (
|
|
126
|
+
Array.isArray(input.paths) &&
|
|
127
|
+
input.paths.length > 0 &&
|
|
128
|
+
input.paths.length <= maxBatchKeys &&
|
|
129
|
+
input.paths.every(isKey)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!isKey(input.path)) return false;
|
|
133
|
+
switch (input.operation) {
|
|
134
|
+
case "get":
|
|
135
|
+
case "delete": {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
case "getUrl":
|
|
139
|
+
case "putUrl": {
|
|
140
|
+
return isSigningOptions(input);
|
|
141
|
+
}
|
|
142
|
+
case "put": {
|
|
143
|
+
return isSize(input.size) && isMetadata(input);
|
|
144
|
+
}
|
|
145
|
+
default: {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseFileBucketRequest(
|
|
152
|
+
value: unknown,
|
|
153
|
+
options?: ParseRequestOptions
|
|
154
|
+
): Readonly<FileBucketRequest> {
|
|
155
|
+
if (!isRecord(value)) throw new Error("Invalid file request");
|
|
156
|
+
// Preserve the original upload protocol, which allowed operation to be omitted.
|
|
157
|
+
const input = {
|
|
158
|
+
...value,
|
|
159
|
+
operation: value.operation === undefined ? "put" : value.operation,
|
|
160
|
+
};
|
|
161
|
+
if (!isRequest(input, options?.maxBatchKeys))
|
|
162
|
+
throw new Error("Invalid file request");
|
|
163
|
+
return deepFreeze(input);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isHttpUrl(value: unknown): value is string {
|
|
167
|
+
if (!isText(value)) return false;
|
|
168
|
+
try {
|
|
169
|
+
const url = new URL(value);
|
|
170
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function isMultipart(value: unknown): value is FileMultipartUploadDescriptor {
|
|
176
|
+
if (!isRecord(value)) return false;
|
|
177
|
+
return (
|
|
178
|
+
value.__blocks === "file-bucket/multipart-upload" &&
|
|
179
|
+
isText(value.uploadId) &&
|
|
180
|
+
isSize(value.size) &&
|
|
181
|
+
isSize(value.partSize) &&
|
|
182
|
+
value.partSize >= 5 * 1024 * 1024 &&
|
|
183
|
+
isSize(value.expiresAt) &&
|
|
184
|
+
Array.isArray(value.partUrls) &&
|
|
185
|
+
value.partUrls.length ===
|
|
186
|
+
Math.max(1, Math.ceil(value.size / value.partSize)) &&
|
|
187
|
+
value.partUrls.length <= 10_000 &&
|
|
188
|
+
value.partUrls.every(isHttpUrl) &&
|
|
189
|
+
isHttpUrl(value.completeUrl) &&
|
|
190
|
+
isHttpUrl(value.abortUrl)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
function isUploadGrant(value: unknown): value is UploadGrant {
|
|
194
|
+
if (!isRecord(value)) return false;
|
|
195
|
+
return value.type === "single"
|
|
196
|
+
? isHttpUrl(value.url) && isStringRecord(value.headers)
|
|
197
|
+
: value.type === "multipart" && isMultipart(value.descriptor);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseFileBucketResponse<O extends FileBucketOperation>(
|
|
201
|
+
operation: O,
|
|
202
|
+
value: unknown
|
|
203
|
+
): FileBucketResponses[O] {
|
|
204
|
+
let valid: boolean;
|
|
205
|
+
switch (operation) {
|
|
206
|
+
case "delete":
|
|
207
|
+
case "deleteBatch": {
|
|
208
|
+
valid = value === undefined;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "put": {
|
|
212
|
+
valid = isUploadGrant(value);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
default: {
|
|
216
|
+
valid = isRecord(value) && isHttpUrl(value.url);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!valid) throw new Error("Invalid file response.");
|
|
220
|
+
// SAFETY: The operation-specific validation above verifies its corresponding response shape.
|
|
221
|
+
return value as FileBucketResponses[O];
|
|
222
|
+
}
|