@beignet/core 0.0.2 → 0.0.3
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/CHANGELOG.md +16 -0
- package/README.md +55 -6
- package/dist/jobs/index.d.ts +138 -4
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +161 -1
- package/dist/jobs/index.js.map +1 -1
- package/dist/outbox/index.d.ts +5 -0
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +59 -3
- package/dist/outbox/index.js.map +1 -1
- package/dist/providers/instrumentation.d.ts +1 -1
- package/dist/providers/instrumentation.d.ts.map +1 -1
- package/dist/providers/instrumentation.js.map +1 -1
- package/dist/server/hooks/auth.d.ts +50 -65
- package/dist/server/hooks/auth.d.ts.map +1 -1
- package/dist/server/hooks/auth.js +44 -55
- package/dist/server/hooks/auth.js.map +1 -1
- package/dist/server/hooks/index.d.ts +1 -1
- package/dist/server/hooks/index.d.ts.map +1 -1
- package/dist/server/hooks/index.js.map +1 -1
- package/dist/server/http.d.ts +52 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +20 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/server.d.ts +54 -13
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +56 -35
- package/dist/server/server.js.map +1 -1
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +8 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/uploads/client.d.ts +278 -0
- package/dist/uploads/client.d.ts.map +1 -0
- package/dist/uploads/client.js +428 -0
- package/dist/uploads/client.js.map +1 -0
- package/dist/uploads/index.d.ts +361 -0
- package/dist/uploads/index.d.ts.map +1 -0
- package/dist/uploads/index.js +543 -0
- package/dist/uploads/index.js.map +1 -0
- package/package.json +11 -2
- package/src/jobs/index.ts +326 -5
- package/src/outbox/index.ts +83 -3
- package/src/providers/instrumentation.ts +7 -1
- package/src/server/hooks/auth.ts +89 -162
- package/src/server/hooks/index.ts +1 -5
- package/src/server/http.ts +79 -0
- package/src/server/index.ts +1 -0
- package/src/server/server.ts +191 -23
- package/src/testing/index.ts +11 -0
- package/src/uploads/client.ts +861 -0
- package/src/uploads/index.ts +1067 -0
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { StorageMetadata, StorageObject, StoragePort } from "../ports";
|
|
3
|
+
import type { ProviderInstrumentationTarget } from "../providers";
|
|
4
|
+
import { createProviderInstrumentation } from "../providers";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Any Standard Schema compatible validator.
|
|
8
|
+
*/
|
|
9
|
+
export type StandardSchema = StandardSchemaV1<unknown, unknown>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Value or promise of that value.
|
|
13
|
+
*/
|
|
14
|
+
export type MaybePromise<T> = T | Promise<T>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Infer the parsed output type from a Standard Schema.
|
|
18
|
+
*/
|
|
19
|
+
export type InferSchemaOutput<T extends StandardSchemaV1> =
|
|
20
|
+
StandardSchemaV1.InferOutput<T>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* File metadata declared by a browser before an upload starts.
|
|
24
|
+
*/
|
|
25
|
+
export interface UploadFileIntent {
|
|
26
|
+
/**
|
|
27
|
+
* Original file name supplied by the client.
|
|
28
|
+
*/
|
|
29
|
+
name: string;
|
|
30
|
+
/**
|
|
31
|
+
* MIME content type supplied by the client.
|
|
32
|
+
*/
|
|
33
|
+
contentType: string;
|
|
34
|
+
/**
|
|
35
|
+
* File size in bytes.
|
|
36
|
+
*/
|
|
37
|
+
size: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Constraints for files accepted by an upload definition.
|
|
42
|
+
*/
|
|
43
|
+
export interface UploadFileConstraints {
|
|
44
|
+
/**
|
|
45
|
+
* Accepted MIME content types. Omit to accept any content type.
|
|
46
|
+
*/
|
|
47
|
+
contentTypes?: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* Maximum accepted file size in bytes.
|
|
50
|
+
*/
|
|
51
|
+
maxSizeBytes?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Maximum files accepted by one request.
|
|
54
|
+
*
|
|
55
|
+
* @default 1
|
|
56
|
+
*/
|
|
57
|
+
maxFiles?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Object visibility written to storage.
|
|
60
|
+
*
|
|
61
|
+
* @default "private"
|
|
62
|
+
*/
|
|
63
|
+
visibility?: "private" | "public";
|
|
64
|
+
/**
|
|
65
|
+
* Cache-Control value written to storage.
|
|
66
|
+
*/
|
|
67
|
+
cacheControl?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parsed file intent with a generated upload id and storage key.
|
|
72
|
+
*/
|
|
73
|
+
export interface PreparedUploadFile extends UploadFileIntent {
|
|
74
|
+
/**
|
|
75
|
+
* Stable upload id used to correlate prepare, direct upload, and complete.
|
|
76
|
+
*/
|
|
77
|
+
uploadId: string;
|
|
78
|
+
/**
|
|
79
|
+
* Storage key that should receive the file.
|
|
80
|
+
*/
|
|
81
|
+
key: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Direct upload instruction returned by an upload signer.
|
|
86
|
+
*/
|
|
87
|
+
export interface DirectUploadInstruction {
|
|
88
|
+
method: "PUT";
|
|
89
|
+
url: string;
|
|
90
|
+
headers?: Record<string, string>;
|
|
91
|
+
expiresAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Arguments passed to an upload signer.
|
|
96
|
+
*/
|
|
97
|
+
export interface SignUploadArgs {
|
|
98
|
+
uploadName: string;
|
|
99
|
+
uploadId: string;
|
|
100
|
+
key: string;
|
|
101
|
+
file: UploadFileIntent;
|
|
102
|
+
metadata: unknown;
|
|
103
|
+
storage: {
|
|
104
|
+
visibility: "private" | "public";
|
|
105
|
+
cacheControl?: string;
|
|
106
|
+
metadata: StorageMetadata;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Port for providers that can create direct-upload instructions.
|
|
112
|
+
*/
|
|
113
|
+
export interface UploadSignerPort {
|
|
114
|
+
/**
|
|
115
|
+
* Create direct upload instructions for one prepared file.
|
|
116
|
+
*/
|
|
117
|
+
sign(args: SignUploadArgs): MaybePromise<DirectUploadInstruction>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Prepared upload file returned to clients.
|
|
122
|
+
*/
|
|
123
|
+
export interface PreparedUploadResultFile extends PreparedUploadFile {
|
|
124
|
+
/**
|
|
125
|
+
* Direct upload instruction when a signer is configured.
|
|
126
|
+
*/
|
|
127
|
+
direct?: DirectUploadInstruction;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Result returned by `prepare(...)`.
|
|
132
|
+
*/
|
|
133
|
+
export interface PrepareUploadResult {
|
|
134
|
+
uploadName: string;
|
|
135
|
+
mode: "direct" | "server";
|
|
136
|
+
files: PreparedUploadResultFile[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* File object completed by direct upload or server upload.
|
|
141
|
+
*/
|
|
142
|
+
export interface CompletedUploadFile extends PreparedUploadFile {
|
|
143
|
+
object: StorageObject;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Result returned by upload completion.
|
|
148
|
+
*/
|
|
149
|
+
export interface CompleteUploadResult<Result = unknown> {
|
|
150
|
+
uploadName: string;
|
|
151
|
+
files: CompletedUploadFile[];
|
|
152
|
+
result: Result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Input for `prepare(...)`.
|
|
157
|
+
*/
|
|
158
|
+
export interface PrepareUploadInput {
|
|
159
|
+
metadata: unknown;
|
|
160
|
+
files: readonly UploadFileIntent[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Input for `complete(...)`.
|
|
165
|
+
*/
|
|
166
|
+
export interface CompleteUploadInput {
|
|
167
|
+
metadata: unknown;
|
|
168
|
+
files: readonly PreparedUploadFile[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Input for server-handled multipart uploads.
|
|
173
|
+
*/
|
|
174
|
+
export interface ServerUploadInput {
|
|
175
|
+
formData: FormData;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Authorization result accepted by upload definitions.
|
|
180
|
+
*/
|
|
181
|
+
export type UploadAuthorizeResult =
|
|
182
|
+
| boolean
|
|
183
|
+
| undefined
|
|
184
|
+
| {
|
|
185
|
+
allowed: boolean;
|
|
186
|
+
reason?: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Arguments passed to upload definition hooks.
|
|
191
|
+
*/
|
|
192
|
+
export interface UploadHookArgs<Metadata, Ctx> {
|
|
193
|
+
ctx: Ctx;
|
|
194
|
+
metadata: Metadata;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Arguments passed to file-specific upload hooks.
|
|
199
|
+
*/
|
|
200
|
+
export interface UploadFileHookArgs<Metadata, Ctx>
|
|
201
|
+
extends UploadHookArgs<Metadata, Ctx> {
|
|
202
|
+
file: UploadFileIntent;
|
|
203
|
+
uploadId: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Arguments passed to `onComplete(...)`.
|
|
208
|
+
*/
|
|
209
|
+
export interface UploadCompleteHookArgs<Metadata, Ctx>
|
|
210
|
+
extends UploadHookArgs<Metadata, Ctx> {
|
|
211
|
+
files: CompletedUploadFile[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Upload definition options.
|
|
216
|
+
*/
|
|
217
|
+
export interface DefineUploadOptions<
|
|
218
|
+
MetadataSchema extends StandardSchema,
|
|
219
|
+
Ctx,
|
|
220
|
+
Result,
|
|
221
|
+
> {
|
|
222
|
+
/**
|
|
223
|
+
* Metadata schema submitted with prepare, server upload, and complete calls.
|
|
224
|
+
*/
|
|
225
|
+
metadata: MetadataSchema;
|
|
226
|
+
/**
|
|
227
|
+
* File constraints for this upload workflow.
|
|
228
|
+
*/
|
|
229
|
+
file: UploadFileConstraints;
|
|
230
|
+
/**
|
|
231
|
+
* Optional human-readable description for docs and tooling.
|
|
232
|
+
*/
|
|
233
|
+
description?: string;
|
|
234
|
+
/**
|
|
235
|
+
* Check whether the current actor may start this upload.
|
|
236
|
+
*/
|
|
237
|
+
authorize?(
|
|
238
|
+
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
239
|
+
): MaybePromise<UploadAuthorizeResult>;
|
|
240
|
+
/**
|
|
241
|
+
* Build the storage key for one file.
|
|
242
|
+
*/
|
|
243
|
+
key(
|
|
244
|
+
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
245
|
+
): MaybePromise<string>;
|
|
246
|
+
/**
|
|
247
|
+
* Add storage metadata for one file.
|
|
248
|
+
*/
|
|
249
|
+
storageMetadata?(
|
|
250
|
+
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
251
|
+
): MaybePromise<StorageMetadata>;
|
|
252
|
+
/**
|
|
253
|
+
* Run after files exist in storage.
|
|
254
|
+
*/
|
|
255
|
+
onComplete?(
|
|
256
|
+
args: UploadCompleteHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
257
|
+
): MaybePromise<Result>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Upload definition created by `defineUpload(...)`.
|
|
262
|
+
*/
|
|
263
|
+
export interface UploadDef<
|
|
264
|
+
Name extends string = string,
|
|
265
|
+
MetadataSchema extends StandardSchema = StandardSchema,
|
|
266
|
+
Ctx = unknown,
|
|
267
|
+
Result = unknown,
|
|
268
|
+
> {
|
|
269
|
+
readonly kind: "upload";
|
|
270
|
+
readonly name: Name;
|
|
271
|
+
readonly metadata: MetadataSchema;
|
|
272
|
+
readonly file: UploadFileConstraints;
|
|
273
|
+
readonly description?: string;
|
|
274
|
+
authorize?(
|
|
275
|
+
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
276
|
+
): MaybePromise<UploadAuthorizeResult>;
|
|
277
|
+
key(
|
|
278
|
+
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
279
|
+
): MaybePromise<string>;
|
|
280
|
+
storageMetadata?(
|
|
281
|
+
args: UploadFileHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
282
|
+
): MaybePromise<StorageMetadata>;
|
|
283
|
+
onComplete?(
|
|
284
|
+
args: UploadCompleteHookArgs<InferSchemaOutput<MetadataSchema>, Ctx>,
|
|
285
|
+
): MaybePromise<Result>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Infer the parsed metadata type for an upload definition.
|
|
290
|
+
*/
|
|
291
|
+
export type InferUploadMetadata<U extends UploadDef> =
|
|
292
|
+
U["metadata"] extends StandardSchemaV1<unknown, infer Output>
|
|
293
|
+
? Output
|
|
294
|
+
: never;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Infer the result returned by an upload definition's completion hook.
|
|
298
|
+
*/
|
|
299
|
+
export type InferUploadResult<U extends UploadDef> =
|
|
300
|
+
U extends UploadDef<string, StandardSchema, unknown, infer Result>
|
|
301
|
+
? Result
|
|
302
|
+
: unknown;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Nested upload registry shape used by server registration and typed clients.
|
|
306
|
+
*/
|
|
307
|
+
export interface UploadRegistry {
|
|
308
|
+
/**
|
|
309
|
+
* Upload definition or nested upload registry.
|
|
310
|
+
*/
|
|
311
|
+
readonly [key: string]: UploadDef | UploadRegistry;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Infer every upload definition contained in a nested upload registry.
|
|
316
|
+
*/
|
|
317
|
+
export type UploadFromRegistry<Registry> = Registry extends UploadDef
|
|
318
|
+
? Registry
|
|
319
|
+
: Registry extends readonly (infer Upload)[]
|
|
320
|
+
? Upload extends UploadDef
|
|
321
|
+
? Upload
|
|
322
|
+
: never
|
|
323
|
+
: Registry extends object
|
|
324
|
+
? {
|
|
325
|
+
[Key in keyof Registry]: UploadFromRegistry<Registry[Key]>;
|
|
326
|
+
}[keyof Registry]
|
|
327
|
+
: never;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Client-safe upload metadata generated from server upload definitions.
|
|
331
|
+
*/
|
|
332
|
+
export interface UploadManifestEntry {
|
|
333
|
+
/**
|
|
334
|
+
* Upload route name.
|
|
335
|
+
*/
|
|
336
|
+
name: string;
|
|
337
|
+
/**
|
|
338
|
+
* Optional human-readable upload description.
|
|
339
|
+
*/
|
|
340
|
+
description?: string;
|
|
341
|
+
/**
|
|
342
|
+
* File constraints safe to expose to browser UI code.
|
|
343
|
+
*/
|
|
344
|
+
file: UploadFileConstraints;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Options for `createUploadRouter(...)`.
|
|
349
|
+
*/
|
|
350
|
+
export interface CreateUploadRouterOptions<Ctx> {
|
|
351
|
+
/**
|
|
352
|
+
* Upload definitions registered with this router.
|
|
353
|
+
*/
|
|
354
|
+
uploads: readonly UploadDef<string, StandardSchema, Ctx, unknown>[];
|
|
355
|
+
/**
|
|
356
|
+
* Request context value or lazy context factory.
|
|
357
|
+
*/
|
|
358
|
+
ctx: Ctx | (() => MaybePromise<Ctx>);
|
|
359
|
+
/**
|
|
360
|
+
* Storage port used for server uploads and direct upload completion checks.
|
|
361
|
+
*/
|
|
362
|
+
storage: StoragePort;
|
|
363
|
+
/**
|
|
364
|
+
* Optional signer used to prepare direct upload instructions.
|
|
365
|
+
*/
|
|
366
|
+
signer?: UploadSignerPort;
|
|
367
|
+
/**
|
|
368
|
+
* Optional instrumentation target used by devtools/provider watchers.
|
|
369
|
+
*/
|
|
370
|
+
instrumentation?: ProviderInstrumentationTarget;
|
|
371
|
+
/**
|
|
372
|
+
* Optional upload id generator for tests or custom id policies.
|
|
373
|
+
*/
|
|
374
|
+
id?: () => string;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Framework-neutral upload router.
|
|
379
|
+
*/
|
|
380
|
+
export interface UploadRouter {
|
|
381
|
+
prepare(
|
|
382
|
+
uploadName: string,
|
|
383
|
+
input: PrepareUploadInput,
|
|
384
|
+
): Promise<PrepareUploadResult>;
|
|
385
|
+
complete(
|
|
386
|
+
uploadName: string,
|
|
387
|
+
input: CompleteUploadInput,
|
|
388
|
+
): Promise<CompleteUploadResult>;
|
|
389
|
+
upload(
|
|
390
|
+
uploadName: string,
|
|
391
|
+
input: ServerUploadInput,
|
|
392
|
+
): Promise<CompleteUploadResult>;
|
|
393
|
+
handleRequest(
|
|
394
|
+
request: Request,
|
|
395
|
+
options: { uploadName: string; action: "prepare" | "complete" | "upload" },
|
|
396
|
+
): Promise<Response>;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Machine-readable upload error codes.
|
|
401
|
+
*/
|
|
402
|
+
export type UploadErrorCode =
|
|
403
|
+
| "UPLOAD_NOT_FOUND"
|
|
404
|
+
| "INVALID_UPLOAD_ACTION"
|
|
405
|
+
| "INVALID_UPLOAD_METADATA"
|
|
406
|
+
| "INVALID_UPLOAD_FILE"
|
|
407
|
+
| "UNAUTHORIZED_UPLOAD"
|
|
408
|
+
| "UPLOAD_OBJECT_NOT_FOUND"
|
|
409
|
+
| "INVALID_UPLOAD_BODY";
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Error thrown for expected upload failures.
|
|
413
|
+
*/
|
|
414
|
+
export class UploadError extends Error {
|
|
415
|
+
readonly code: UploadErrorCode;
|
|
416
|
+
readonly status: number;
|
|
417
|
+
readonly details?: unknown;
|
|
418
|
+
|
|
419
|
+
constructor(args: {
|
|
420
|
+
code: UploadErrorCode;
|
|
421
|
+
message: string;
|
|
422
|
+
status?: number;
|
|
423
|
+
details?: unknown;
|
|
424
|
+
}) {
|
|
425
|
+
super(args.message);
|
|
426
|
+
this.name = "UploadError";
|
|
427
|
+
this.code = args.code;
|
|
428
|
+
this.status = args.status ?? 400;
|
|
429
|
+
this.details = args.details;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Define a typed upload workflow.
|
|
435
|
+
*/
|
|
436
|
+
export function defineUpload<
|
|
437
|
+
const Name extends string,
|
|
438
|
+
MetadataSchema extends StandardSchema,
|
|
439
|
+
Ctx = unknown,
|
|
440
|
+
Result = unknown,
|
|
441
|
+
>(
|
|
442
|
+
name: Name,
|
|
443
|
+
options: DefineUploadOptions<MetadataSchema, Ctx, Result>,
|
|
444
|
+
): UploadDef<Name, MetadataSchema, Ctx, Result> {
|
|
445
|
+
return {
|
|
446
|
+
kind: "upload",
|
|
447
|
+
name,
|
|
448
|
+
metadata: options.metadata,
|
|
449
|
+
file: {
|
|
450
|
+
...options.file,
|
|
451
|
+
maxFiles: options.file.maxFiles ?? 1,
|
|
452
|
+
visibility: options.file.visibility ?? "private",
|
|
453
|
+
},
|
|
454
|
+
...(options.description !== undefined
|
|
455
|
+
? { description: options.description }
|
|
456
|
+
: {}),
|
|
457
|
+
...(options.authorize ? { authorize: options.authorize } : {}),
|
|
458
|
+
key: options.key,
|
|
459
|
+
...(options.storageMetadata
|
|
460
|
+
? { storageMetadata: options.storageMetadata }
|
|
461
|
+
: {}),
|
|
462
|
+
...(options.onComplete ? { onComplete: options.onComplete } : {}),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Define a nested upload registry while preserving upload names and metadata
|
|
468
|
+
* types for client code.
|
|
469
|
+
*/
|
|
470
|
+
export function defineUploads<const Uploads extends UploadRegistry>(
|
|
471
|
+
uploads: Uploads,
|
|
472
|
+
): Uploads {
|
|
473
|
+
return uploads;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Flatten a nested upload registry into the list expected by
|
|
478
|
+
* `createUploadRouter(...)`.
|
|
479
|
+
*/
|
|
480
|
+
export function uploadsFromRegistry(
|
|
481
|
+
uploads: UploadRegistry | readonly UploadDef[],
|
|
482
|
+
): UploadDef[] {
|
|
483
|
+
if (Array.isArray(uploads)) return [...uploads];
|
|
484
|
+
|
|
485
|
+
const result: UploadDef[] = [];
|
|
486
|
+
for (const value of Object.values(uploads)) {
|
|
487
|
+
if (isUploadDef(value)) {
|
|
488
|
+
result.push(value);
|
|
489
|
+
} else {
|
|
490
|
+
result.push(...uploadsFromRegistry(value));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Create client-safe upload metadata for browser helpers.
|
|
498
|
+
*/
|
|
499
|
+
export function createUploadManifest(
|
|
500
|
+
uploads: UploadRegistry | readonly UploadDef[],
|
|
501
|
+
): UploadManifestEntry[] {
|
|
502
|
+
return uploadsFromRegistry(uploads).map((upload) => ({
|
|
503
|
+
name: upload.name,
|
|
504
|
+
...(upload.description !== undefined
|
|
505
|
+
? { description: upload.description }
|
|
506
|
+
: {}),
|
|
507
|
+
file: upload.file,
|
|
508
|
+
}));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Create deterministic direct upload instructions for tests.
|
|
513
|
+
*/
|
|
514
|
+
export function createMemoryUploadSigner(
|
|
515
|
+
options: { baseUrl?: string; expiresAt?: string } = {},
|
|
516
|
+
): UploadSignerPort {
|
|
517
|
+
const baseUrl = options.baseUrl ?? "https://uploads.beignet.test";
|
|
518
|
+
const expiresAt = options.expiresAt ?? "2100-01-01T00:00:00.000Z";
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
sign(args) {
|
|
522
|
+
return {
|
|
523
|
+
method: "PUT",
|
|
524
|
+
url: `${baseUrl}/${encodeURIComponent(args.key)}`,
|
|
525
|
+
headers: {
|
|
526
|
+
"content-type": args.file.contentType,
|
|
527
|
+
},
|
|
528
|
+
expiresAt,
|
|
529
|
+
};
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Create a framework-neutral upload router.
|
|
536
|
+
*/
|
|
537
|
+
export function createUploadRouter<Ctx>(
|
|
538
|
+
options: CreateUploadRouterOptions<Ctx>,
|
|
539
|
+
): UploadRouter {
|
|
540
|
+
const uploads = new Map(
|
|
541
|
+
options.uploads.map((upload) => [upload.name, upload]),
|
|
542
|
+
);
|
|
543
|
+
const id = options.id ?? randomUploadId;
|
|
544
|
+
const instrumentation = createProviderInstrumentation(
|
|
545
|
+
options.instrumentation,
|
|
546
|
+
{
|
|
547
|
+
providerName: "uploads",
|
|
548
|
+
watcher: "uploads",
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
async function resolveCtx(): Promise<Ctx> {
|
|
553
|
+
return typeof options.ctx === "function"
|
|
554
|
+
? (options.ctx as () => MaybePromise<Ctx>)()
|
|
555
|
+
: options.ctx;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function findUpload(name: string) {
|
|
559
|
+
const upload = uploads.get(name);
|
|
560
|
+
if (!upload) {
|
|
561
|
+
throw new UploadError({
|
|
562
|
+
code: "UPLOAD_NOT_FOUND",
|
|
563
|
+
status: 404,
|
|
564
|
+
message: `Upload "${name}" is not registered.`,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return upload;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function prepare(uploadName: string, input: PrepareUploadInput) {
|
|
571
|
+
const startedAt = Date.now();
|
|
572
|
+
instrumentation.custom({
|
|
573
|
+
name: "upload.prepare.started",
|
|
574
|
+
label: "Upload prepare started",
|
|
575
|
+
summary: uploadName,
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const upload = findUpload(uploadName);
|
|
580
|
+
const ctx = await resolveCtx();
|
|
581
|
+
const metadata = await parseMetadata(upload, input.metadata);
|
|
582
|
+
assertFiles(upload, input.files);
|
|
583
|
+
|
|
584
|
+
const files: PreparedUploadResultFile[] = [];
|
|
585
|
+
for (const file of input.files) {
|
|
586
|
+
const uploadId = id();
|
|
587
|
+
await assertAuthorized(upload, { ctx, metadata, file, uploadId });
|
|
588
|
+
const key = await upload.key({ ctx, metadata, file, uploadId });
|
|
589
|
+
const storageMetadata =
|
|
590
|
+
(await upload.storageMetadata?.({ ctx, metadata, file, uploadId })) ??
|
|
591
|
+
{};
|
|
592
|
+
const prepared: PreparedUploadResultFile = {
|
|
593
|
+
...file,
|
|
594
|
+
uploadId,
|
|
595
|
+
key,
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
if (options.signer) {
|
|
599
|
+
prepared.direct = await options.signer.sign({
|
|
600
|
+
uploadName,
|
|
601
|
+
uploadId,
|
|
602
|
+
key,
|
|
603
|
+
file,
|
|
604
|
+
metadata,
|
|
605
|
+
storage: {
|
|
606
|
+
visibility: upload.file.visibility ?? "private",
|
|
607
|
+
...(upload.file.cacheControl !== undefined
|
|
608
|
+
? { cacheControl: upload.file.cacheControl }
|
|
609
|
+
: {}),
|
|
610
|
+
metadata: storageMetadata,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
files.push(prepared);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
instrumentation.custom({
|
|
619
|
+
name: "upload.prepare.completed",
|
|
620
|
+
label: "Upload prepare completed",
|
|
621
|
+
summary: `${uploadName} (${files.length} file${files.length === 1 ? "" : "s"})`,
|
|
622
|
+
details: {
|
|
623
|
+
uploadName,
|
|
624
|
+
mode: options.signer ? "direct" : "server",
|
|
625
|
+
fileCount: files.length,
|
|
626
|
+
durationMs: Date.now() - startedAt,
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
uploadName,
|
|
632
|
+
mode: options.signer ? "direct" : "server",
|
|
633
|
+
files,
|
|
634
|
+
} satisfies PrepareUploadResult;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
recordFailure("upload.prepare.failed", uploadName, startedAt, error);
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function complete(uploadName: string, input: CompleteUploadInput) {
|
|
642
|
+
const startedAt = Date.now();
|
|
643
|
+
instrumentation.custom({
|
|
644
|
+
name: "upload.complete.started",
|
|
645
|
+
label: "Upload complete started",
|
|
646
|
+
summary: uploadName,
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
const upload = findUpload(uploadName);
|
|
651
|
+
const ctx = await resolveCtx();
|
|
652
|
+
const metadata = await parseMetadata(upload, input.metadata);
|
|
653
|
+
assertFiles(upload, input.files);
|
|
654
|
+
|
|
655
|
+
const files: CompletedUploadFile[] = [];
|
|
656
|
+
for (const file of input.files) {
|
|
657
|
+
await assertAuthorized(upload, {
|
|
658
|
+
ctx,
|
|
659
|
+
metadata,
|
|
660
|
+
file,
|
|
661
|
+
uploadId: file.uploadId,
|
|
662
|
+
});
|
|
663
|
+
const expectedKey = await upload.key({
|
|
664
|
+
ctx,
|
|
665
|
+
metadata,
|
|
666
|
+
file,
|
|
667
|
+
uploadId: file.uploadId,
|
|
668
|
+
});
|
|
669
|
+
if (file.key !== expectedKey) {
|
|
670
|
+
throw new UploadError({
|
|
671
|
+
code: "INVALID_UPLOAD_FILE",
|
|
672
|
+
status: 422,
|
|
673
|
+
message: `Uploaded object key does not match upload "${upload.name}".`,
|
|
674
|
+
details: {
|
|
675
|
+
expectedKey,
|
|
676
|
+
actualKey: file.key,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
const object = await options.storage.stat(file.key);
|
|
681
|
+
if (!object) {
|
|
682
|
+
throw new UploadError({
|
|
683
|
+
code: "UPLOAD_OBJECT_NOT_FOUND",
|
|
684
|
+
status: 404,
|
|
685
|
+
message: `Uploaded object "${file.key}" was not found.`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
assertStoredObject(upload, file, object);
|
|
689
|
+
files.push({ ...file, object });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const result = await upload.onComplete?.({ ctx, metadata, files });
|
|
693
|
+
instrumentation.custom({
|
|
694
|
+
name: "upload.complete.completed",
|
|
695
|
+
label: "Upload complete completed",
|
|
696
|
+
summary: `${uploadName} (${files.length} file${files.length === 1 ? "" : "s"})`,
|
|
697
|
+
details: {
|
|
698
|
+
uploadName,
|
|
699
|
+
fileCount: files.length,
|
|
700
|
+
durationMs: Date.now() - startedAt,
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
uploadName,
|
|
706
|
+
files,
|
|
707
|
+
result,
|
|
708
|
+
} satisfies CompleteUploadResult;
|
|
709
|
+
} catch (error) {
|
|
710
|
+
recordFailure("upload.complete.failed", uploadName, startedAt, error);
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function upload(uploadName: string, input: ServerUploadInput) {
|
|
716
|
+
const startedAt = Date.now();
|
|
717
|
+
instrumentation.custom({
|
|
718
|
+
name: "upload.server.started",
|
|
719
|
+
label: "Server upload started",
|
|
720
|
+
summary: uploadName,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const definition = findUpload(uploadName);
|
|
725
|
+
const ctx = await resolveCtx();
|
|
726
|
+
const metadata = await parseMetadata(
|
|
727
|
+
definition,
|
|
728
|
+
metadataFromFormData(input.formData),
|
|
729
|
+
);
|
|
730
|
+
const webFiles = filesFromFormData(input.formData);
|
|
731
|
+
const intents = webFiles.map(fileIntentFromFile);
|
|
732
|
+
assertFiles(definition, intents);
|
|
733
|
+
|
|
734
|
+
const completed: CompletedUploadFile[] = [];
|
|
735
|
+
for (const [index, file] of webFiles.entries()) {
|
|
736
|
+
const intent = intents[index];
|
|
737
|
+
if (!intent) continue;
|
|
738
|
+
const uploadId = id();
|
|
739
|
+
await assertAuthorized(definition, {
|
|
740
|
+
ctx,
|
|
741
|
+
metadata,
|
|
742
|
+
file: intent,
|
|
743
|
+
uploadId,
|
|
744
|
+
});
|
|
745
|
+
const key = await definition.key({
|
|
746
|
+
ctx,
|
|
747
|
+
metadata,
|
|
748
|
+
file: intent,
|
|
749
|
+
uploadId,
|
|
750
|
+
});
|
|
751
|
+
const storageMetadata =
|
|
752
|
+
(await definition.storageMetadata?.({
|
|
753
|
+
ctx,
|
|
754
|
+
metadata,
|
|
755
|
+
file: intent,
|
|
756
|
+
uploadId,
|
|
757
|
+
})) ?? {};
|
|
758
|
+
const object = await options.storage.put(key, file, {
|
|
759
|
+
contentType: intent.contentType,
|
|
760
|
+
...(definition.file.cacheControl !== undefined
|
|
761
|
+
? { cacheControl: definition.file.cacheControl }
|
|
762
|
+
: {}),
|
|
763
|
+
metadata: storageMetadata,
|
|
764
|
+
visibility: definition.file.visibility ?? "private",
|
|
765
|
+
});
|
|
766
|
+
completed.push({
|
|
767
|
+
...intent,
|
|
768
|
+
uploadId,
|
|
769
|
+
key,
|
|
770
|
+
object,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const result = await definition.onComplete?.({
|
|
775
|
+
ctx,
|
|
776
|
+
metadata,
|
|
777
|
+
files: completed,
|
|
778
|
+
});
|
|
779
|
+
instrumentation.custom({
|
|
780
|
+
name: "upload.server.completed",
|
|
781
|
+
label: "Server upload completed",
|
|
782
|
+
summary: `${uploadName} (${completed.length} file${completed.length === 1 ? "" : "s"})`,
|
|
783
|
+
details: {
|
|
784
|
+
uploadName,
|
|
785
|
+
fileCount: completed.length,
|
|
786
|
+
durationMs: Date.now() - startedAt,
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
uploadName,
|
|
792
|
+
files: completed,
|
|
793
|
+
result,
|
|
794
|
+
} satisfies CompleteUploadResult;
|
|
795
|
+
} catch (error) {
|
|
796
|
+
recordFailure("upload.server.failed", uploadName, startedAt, error);
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function recordFailure(
|
|
802
|
+
name: string,
|
|
803
|
+
uploadName: string,
|
|
804
|
+
startedAt: number,
|
|
805
|
+
error: unknown,
|
|
806
|
+
) {
|
|
807
|
+
instrumentation.custom({
|
|
808
|
+
name,
|
|
809
|
+
label: "Upload failed",
|
|
810
|
+
summary: uploadName,
|
|
811
|
+
details: {
|
|
812
|
+
uploadName,
|
|
813
|
+
durationMs: Date.now() - startedAt,
|
|
814
|
+
error: error instanceof Error ? error.message : String(error),
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return {
|
|
820
|
+
prepare,
|
|
821
|
+
complete,
|
|
822
|
+
upload,
|
|
823
|
+
async handleRequest(request, requestOptions) {
|
|
824
|
+
try {
|
|
825
|
+
if (requestOptions.action === "prepare") {
|
|
826
|
+
const input = (await request.json()) as PrepareUploadInput;
|
|
827
|
+
return jsonResponse(await prepare(requestOptions.uploadName, input));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (requestOptions.action === "complete") {
|
|
831
|
+
const input = (await request.json()) as CompleteUploadInput;
|
|
832
|
+
return jsonResponse(await complete(requestOptions.uploadName, input));
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const formData = await request.formData();
|
|
836
|
+
return jsonResponse(
|
|
837
|
+
await upload(requestOptions.uploadName, {
|
|
838
|
+
formData,
|
|
839
|
+
}),
|
|
840
|
+
);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
return uploadErrorResponse(error);
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function parseMetadata<U extends UploadDef>(
|
|
849
|
+
upload: U,
|
|
850
|
+
input: unknown,
|
|
851
|
+
): Promise<InferUploadMetadata<U>> {
|
|
852
|
+
const result = await upload.metadata["~standard"].validate(input);
|
|
853
|
+
|
|
854
|
+
if (result.issues?.length) {
|
|
855
|
+
throw new UploadError({
|
|
856
|
+
code: "INVALID_UPLOAD_METADATA",
|
|
857
|
+
status: 422,
|
|
858
|
+
message: `Invalid metadata for upload "${upload.name}".`,
|
|
859
|
+
details: { issues: result.issues },
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if ("value" in result) {
|
|
864
|
+
return result.value as InferUploadMetadata<U>;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
throw new Error("Invalid Standard Schema result: missing value");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function assertFiles(
|
|
871
|
+
upload: UploadDef,
|
|
872
|
+
files: readonly UploadFileIntent[],
|
|
873
|
+
): void {
|
|
874
|
+
const maxFiles = upload.file.maxFiles ?? 1;
|
|
875
|
+
if (files.length === 0 || files.length > maxFiles) {
|
|
876
|
+
throw new UploadError({
|
|
877
|
+
code: "INVALID_UPLOAD_FILE",
|
|
878
|
+
status: 422,
|
|
879
|
+
message: `Upload "${upload.name}" requires between 1 and ${maxFiles} file${maxFiles === 1 ? "" : "s"}.`,
|
|
880
|
+
details: { fileCount: files.length, maxFiles },
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
for (const file of files) {
|
|
885
|
+
if (!file.name || !file.contentType || !Number.isFinite(file.size)) {
|
|
886
|
+
throw new UploadError({
|
|
887
|
+
code: "INVALID_UPLOAD_FILE",
|
|
888
|
+
status: 422,
|
|
889
|
+
message: `Upload "${upload.name}" received invalid file metadata.`,
|
|
890
|
+
details: { file },
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (
|
|
895
|
+
upload.file.contentTypes?.length &&
|
|
896
|
+
!upload.file.contentTypes.includes(file.contentType)
|
|
897
|
+
) {
|
|
898
|
+
throw new UploadError({
|
|
899
|
+
code: "INVALID_UPLOAD_FILE",
|
|
900
|
+
status: 415,
|
|
901
|
+
message: `Upload "${upload.name}" does not accept "${file.contentType}".`,
|
|
902
|
+
details: {
|
|
903
|
+
contentType: file.contentType,
|
|
904
|
+
acceptedContentTypes: upload.file.contentTypes,
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (
|
|
910
|
+
upload.file.maxSizeBytes !== undefined &&
|
|
911
|
+
file.size > upload.file.maxSizeBytes
|
|
912
|
+
) {
|
|
913
|
+
throw new UploadError({
|
|
914
|
+
code: "INVALID_UPLOAD_FILE",
|
|
915
|
+
status: 413,
|
|
916
|
+
message: `Upload "${upload.name}" exceeds the maximum file size.`,
|
|
917
|
+
details: {
|
|
918
|
+
size: file.size,
|
|
919
|
+
maxSizeBytes: upload.file.maxSizeBytes,
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function assertAuthorized<Metadata, Ctx>(
|
|
927
|
+
upload: UploadDef<string, StandardSchema, Ctx>,
|
|
928
|
+
args: UploadFileHookArgs<Metadata, Ctx>,
|
|
929
|
+
): Promise<void> {
|
|
930
|
+
const result = await upload.authorize?.(
|
|
931
|
+
args as UploadFileHookArgs<InferUploadMetadata<typeof upload>, Ctx>,
|
|
932
|
+
);
|
|
933
|
+
const denied =
|
|
934
|
+
result === false ||
|
|
935
|
+
(typeof result === "object" &&
|
|
936
|
+
result !== null &&
|
|
937
|
+
"allowed" in result &&
|
|
938
|
+
result.allowed === false);
|
|
939
|
+
if (!denied) return;
|
|
940
|
+
|
|
941
|
+
throw new UploadError({
|
|
942
|
+
code: "UNAUTHORIZED_UPLOAD",
|
|
943
|
+
status: 403,
|
|
944
|
+
message:
|
|
945
|
+
typeof result === "object" && result?.reason
|
|
946
|
+
? result.reason
|
|
947
|
+
: `Upload "${upload.name}" is not authorized.`,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function assertStoredObject(
|
|
952
|
+
upload: UploadDef,
|
|
953
|
+
file: UploadFileIntent,
|
|
954
|
+
object: StorageObject,
|
|
955
|
+
): void {
|
|
956
|
+
assertFiles(upload, [file]);
|
|
957
|
+
|
|
958
|
+
if (
|
|
959
|
+
object.contentType &&
|
|
960
|
+
file.contentType &&
|
|
961
|
+
object.contentType !== file.contentType
|
|
962
|
+
) {
|
|
963
|
+
throw new UploadError({
|
|
964
|
+
code: "INVALID_UPLOAD_FILE",
|
|
965
|
+
status: 422,
|
|
966
|
+
message: `Uploaded object "${object.key}" content type does not match the prepared file.`,
|
|
967
|
+
details: {
|
|
968
|
+
expected: file.contentType,
|
|
969
|
+
actual: object.contentType,
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function metadataFromFormData(formData: FormData): unknown {
|
|
976
|
+
const metadata = formData.get("metadata");
|
|
977
|
+
if (typeof metadata !== "string") return {};
|
|
978
|
+
|
|
979
|
+
try {
|
|
980
|
+
return JSON.parse(metadata);
|
|
981
|
+
} catch {
|
|
982
|
+
throw new UploadError({
|
|
983
|
+
code: "INVALID_UPLOAD_BODY",
|
|
984
|
+
status: 400,
|
|
985
|
+
message: "Upload metadata must be valid JSON.",
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function filesFromFormData(formData: FormData): File[] {
|
|
991
|
+
const values = [...formData.getAll("file"), ...formData.getAll("files")];
|
|
992
|
+
const files = values.filter((value): value is File => value instanceof File);
|
|
993
|
+
if (files.length === 0) {
|
|
994
|
+
throw new UploadError({
|
|
995
|
+
code: "INVALID_UPLOAD_BODY",
|
|
996
|
+
status: 400,
|
|
997
|
+
message: 'Multipart upload must include at least one "file" field.',
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return files;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function fileIntentFromFile(file: File): UploadFileIntent {
|
|
1004
|
+
return {
|
|
1005
|
+
name: file.name,
|
|
1006
|
+
contentType: file.type || "application/octet-stream",
|
|
1007
|
+
size: file.size,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
1012
|
+
const headers: Record<string, string> = {
|
|
1013
|
+
"content-type": "application/json",
|
|
1014
|
+
};
|
|
1015
|
+
if (init?.headers) {
|
|
1016
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
1017
|
+
headers[key] = value;
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return new Response(JSON.stringify(body), {
|
|
1022
|
+
status: init?.status ?? 200,
|
|
1023
|
+
headers,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function uploadErrorResponse(error: unknown): Response {
|
|
1028
|
+
if (error instanceof UploadError) {
|
|
1029
|
+
return jsonResponse(
|
|
1030
|
+
{
|
|
1031
|
+
error: {
|
|
1032
|
+
code: error.code,
|
|
1033
|
+
message: error.message,
|
|
1034
|
+
...(error.details !== undefined ? { details: error.details } : {}),
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
{ status: error.status },
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return jsonResponse(
|
|
1042
|
+
{
|
|
1043
|
+
error: {
|
|
1044
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
1045
|
+
message: "Internal server error",
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
{ status: 500 },
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function randomUploadId(): string {
|
|
1053
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
1054
|
+
return crypto.randomUUID();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
return `upload_${Math.random().toString(36).slice(2)}`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function isUploadDef(value: UploadDef | UploadRegistry): value is UploadDef {
|
|
1061
|
+
return (
|
|
1062
|
+
typeof value === "object" &&
|
|
1063
|
+
value !== null &&
|
|
1064
|
+
"kind" in value &&
|
|
1065
|
+
value.kind === "upload"
|
|
1066
|
+
);
|
|
1067
|
+
}
|