@contember/bindx-uploader 0.1.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/dist/UploaderError.d.ts +6 -0
- package/dist/UploaderError.d.ts.map +1 -0
- package/dist/components/MultiUploader.d.ts +43 -0
- package/dist/components/MultiUploader.d.ts.map +1 -0
- package/dist/components/Uploader.d.ts +58 -0
- package/dist/components/Uploader.d.ts.map +1 -0
- package/dist/components/UploaderDropzoneArea.d.ts +6 -0
- package/dist/components/UploaderDropzoneArea.d.ts.map +1 -0
- package/dist/components/UploaderDropzoneRoot.d.ts +8 -0
- package/dist/components/UploaderDropzoneRoot.d.ts.map +1 -0
- package/dist/components/UploaderEachFile.d.ts +14 -0
- package/dist/components/UploaderEachFile.d.ts.map +1 -0
- package/dist/components/UploaderFileStateSwitch.d.ts +24 -0
- package/dist/components/UploaderFileStateSwitch.d.ts.map +1 -0
- package/dist/components/UploaderHasFile.d.ts +14 -0
- package/dist/components/UploaderHasFile.d.ts.map +1 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/contexts.d.ts +32 -0
- package/dist/contexts.d.ts.map +1 -0
- package/dist/extractors/getAudioFileDataExtractor.d.ts +9 -0
- package/dist/extractors/getAudioFileDataExtractor.d.ts.map +1 -0
- package/dist/extractors/getFileUrlDataExtractor.d.ts +9 -0
- package/dist/extractors/getFileUrlDataExtractor.d.ts.map +1 -0
- package/dist/extractors/getGenericFileMetadataExtractor.d.ts +12 -0
- package/dist/extractors/getGenericFileMetadataExtractor.d.ts.map +1 -0
- package/dist/extractors/getImageFileDataExtractor.d.ts +10 -0
- package/dist/extractors/getImageFileDataExtractor.d.ts.map +1 -0
- package/dist/extractors/getVideoFileDataExtractor.d.ts +11 -0
- package/dist/extractors/getVideoFileDataExtractor.d.ts.map +1 -0
- package/dist/extractors/index.d.ts +6 -0
- package/dist/extractors/index.d.ts.map +1 -0
- package/dist/extractors/types.d.ts +13 -0
- package/dist/extractors/types.d.ts.map +1 -0
- package/dist/fileTypes/createAnyFileType.d.ts +27 -0
- package/dist/fileTypes/createAnyFileType.d.ts.map +1 -0
- package/dist/fileTypes/createAudioFileType.d.ts +29 -0
- package/dist/fileTypes/createAudioFileType.d.ts.map +1 -0
- package/dist/fileTypes/createImageFileType.d.ts +47 -0
- package/dist/fileTypes/createImageFileType.d.ts.map +1 -0
- package/dist/fileTypes/createVideoFileType.d.ts +33 -0
- package/dist/fileTypes/createVideoFileType.d.ts.map +1 -0
- package/dist/fileTypes/index.d.ts +5 -0
- package/dist/fileTypes/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useS3Client.d.ts +7 -0
- package/dist/hooks/useS3Client.d.ts.map +1 -0
- package/dist/hooks/useUploaderStateFiles.d.ts +8 -0
- package/dist/hooks/useUploaderStateFiles.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/internal/hooks/index.d.ts +5 -0
- package/dist/internal/hooks/index.d.ts.map +1 -0
- package/dist/internal/hooks/useFillEntity.d.ts +17 -0
- package/dist/internal/hooks/useFillEntity.d.ts.map +1 -0
- package/dist/internal/hooks/useGetPreviewUrls.d.ts +6 -0
- package/dist/internal/hooks/useGetPreviewUrls.d.ts.map +1 -0
- package/dist/internal/hooks/useUploadState.d.ts +12 -0
- package/dist/internal/hooks/useUploadState.d.ts.map +1 -0
- package/dist/internal/hooks/useUploaderDoUpload.d.ts +7 -0
- package/dist/internal/hooks/useUploaderDoUpload.d.ts.map +1 -0
- package/dist/internal/utils/executeExtractors.d.ts +16 -0
- package/dist/internal/utils/executeExtractors.d.ts.map +1 -0
- package/dist/internal/utils/index.d.ts +4 -0
- package/dist/internal/utils/index.d.ts.map +1 -0
- package/dist/internal/utils/resolveAccept.d.ts +7 -0
- package/dist/internal/utils/resolveAccept.d.ts.map +1 -0
- package/dist/internal/utils/uploaderErrorHandler.d.ts +7 -0
- package/dist/internal/utils/uploaderErrorHandler.d.ts.map +1 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/uploadClient/S3UploadClient.d.ts +19 -0
- package/dist/uploadClient/S3UploadClient.d.ts.map +1 -0
- package/dist/uploadClient/index.d.ts +3 -0
- package/dist/uploadClient/index.d.ts.map +1 -0
- package/dist/uploadClient/types.d.ts +29 -0
- package/dist/uploadClient/types.d.ts.map +1 -0
- package/dist/utils/attrAccept.d.ts +16 -0
- package/dist/utils/attrAccept.d.ts.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/urlSigner.d.ts +8 -0
- package/dist/utils/urlSigner.d.ts.map +1 -0
- package/package.json +35 -0
- package/src/UploaderError.ts +7 -0
- package/src/components/MultiUploader.tsx +210 -0
- package/src/components/Uploader.tsx +174 -0
- package/src/components/UploaderDropzoneArea.tsx +29 -0
- package/src/components/UploaderDropzoneRoot.tsx +32 -0
- package/src/components/UploaderEachFile.tsx +46 -0
- package/src/components/UploaderFileStateSwitch.tsx +66 -0
- package/src/components/UploaderHasFile.tsx +35 -0
- package/src/components/index.ts +7 -0
- package/src/contexts.ts +76 -0
- package/src/extractors/getAudioFileDataExtractor.ts +44 -0
- package/src/extractors/getFileUrlDataExtractor.ts +22 -0
- package/src/extractors/getGenericFileMetadataExtractor.ts +40 -0
- package/src/extractors/getImageFileDataExtractor.ts +50 -0
- package/src/extractors/getVideoFileDataExtractor.ts +60 -0
- package/src/extractors/index.ts +5 -0
- package/src/extractors/types.ts +14 -0
- package/src/fileTypes/createAnyFileType.ts +56 -0
- package/src/fileTypes/createAudioFileType.ts +67 -0
- package/src/fileTypes/createImageFileType.ts +87 -0
- package/src/fileTypes/createVideoFileType.ts +75 -0
- package/src/fileTypes/index.ts +4 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useS3Client.ts +26 -0
- package/src/hooks/useUploaderStateFiles.ts +22 -0
- package/src/index.ts +126 -0
- package/src/internal/hooks/index.ts +4 -0
- package/src/internal/hooks/useFillEntity.ts +89 -0
- package/src/internal/hooks/useGetPreviewUrls.ts +26 -0
- package/src/internal/hooks/useUploadState.ts +159 -0
- package/src/internal/hooks/useUploaderDoUpload.ts +129 -0
- package/src/internal/utils/executeExtractors.ts +46 -0
- package/src/internal/utils/index.ts +3 -0
- package/src/internal/utils/resolveAccept.ts +26 -0
- package/src/internal/utils/uploaderErrorHandler.ts +15 -0
- package/src/types.ts +242 -0
- package/src/uploadClient/S3UploadClient.ts +119 -0
- package/src/uploadClient/index.ts +2 -0
- package/src/uploadClient/types.ts +30 -0
- package/src/utils/attrAccept.ts +87 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/urlSigner.ts +117 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { EntityRef, FieldRef, HasManyRef, HasOneRef } from '@contember/bindx'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Upload Client Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export interface UploadClient<
|
|
8
|
+
Options = unknown,
|
|
9
|
+
Result extends FileUploadResult = FileUploadResult,
|
|
10
|
+
> {
|
|
11
|
+
upload: (args: UploadClientUploadArgs & Omit<Options, keyof UploadClientUploadArgs>) => Promise<Result>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FileUploadResult {
|
|
15
|
+
publicUrl: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FileUploadProgress {
|
|
19
|
+
progress: number
|
|
20
|
+
uploadedBytes: number
|
|
21
|
+
totalBytes: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UploadClientUploadArgs {
|
|
25
|
+
file: File
|
|
26
|
+
signal: AbortSignal
|
|
27
|
+
onProgress: (progress: FileUploadProgress) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// File Types
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
export interface FileWithMeta {
|
|
35
|
+
id: string
|
|
36
|
+
file: File
|
|
37
|
+
previewUrl: string
|
|
38
|
+
abortController: AbortController
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Upload State Types
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
export interface UploaderFileStateInitial {
|
|
46
|
+
state: 'initial'
|
|
47
|
+
file: FileWithMeta
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UploaderFileStateUploading {
|
|
51
|
+
state: 'uploading'
|
|
52
|
+
file: FileWithMeta
|
|
53
|
+
progress: FileUploadProgress
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UploaderFileStateFinalizing {
|
|
57
|
+
state: 'finalizing'
|
|
58
|
+
file: FileWithMeta
|
|
59
|
+
result: FileUploadResult
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UploaderFileStateSuccess {
|
|
63
|
+
state: 'success'
|
|
64
|
+
file: FileWithMeta
|
|
65
|
+
result: FileUploadResult
|
|
66
|
+
dismiss: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface UploaderFileStateError {
|
|
70
|
+
state: 'error'
|
|
71
|
+
file: FileWithMeta
|
|
72
|
+
error: unknown
|
|
73
|
+
dismiss: () => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type UploaderFileState =
|
|
77
|
+
| UploaderFileStateInitial
|
|
78
|
+
| UploaderFileStateUploading
|
|
79
|
+
| UploaderFileStateFinalizing
|
|
80
|
+
| UploaderFileStateSuccess
|
|
81
|
+
| UploaderFileStateError
|
|
82
|
+
|
|
83
|
+
export type UploaderState = UploaderFileState[]
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Event Types
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
export interface BeforeUploadEvent {
|
|
90
|
+
file: FileWithMeta
|
|
91
|
+
reject: (reason: string) => never
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface StartUploadEvent {
|
|
95
|
+
file: FileWithMeta
|
|
96
|
+
fileType: FileType
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ProgressEvent {
|
|
100
|
+
file: FileWithMeta
|
|
101
|
+
progress: FileUploadProgress
|
|
102
|
+
fileType: FileType
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SuccessEvent {
|
|
106
|
+
file: FileWithMeta
|
|
107
|
+
result: FileUploadResult
|
|
108
|
+
fileType: FileType
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface AfterUploadEvent {
|
|
112
|
+
file: FileWithMeta
|
|
113
|
+
result: FileUploadResult
|
|
114
|
+
fileType: FileType
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ErrorEvent {
|
|
118
|
+
file: FileWithMeta
|
|
119
|
+
error: unknown
|
|
120
|
+
fileType?: FileType
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface UploaderEvents {
|
|
124
|
+
onBeforeUpload: (event: BeforeUploadEvent) => Promise<FileType | undefined>
|
|
125
|
+
onStartUpload: (event: StartUploadEvent) => void
|
|
126
|
+
onProgress: (event: ProgressEvent) => void
|
|
127
|
+
onAfterUpload: (event: AfterUploadEvent) => Promise<void> | void
|
|
128
|
+
onSuccess: (event: SuccessEvent) => void
|
|
129
|
+
onError: (event: ErrorEvent) => void
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// Extractor Types
|
|
134
|
+
// ============================================================================
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract string keys from entity type (field names).
|
|
138
|
+
*/
|
|
139
|
+
export type FieldName<TEntity> = keyof TEntity & string
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Result from file data extraction that can populate entity fields
|
|
143
|
+
*/
|
|
144
|
+
export type FileDataExtractorPopulator<TEntity> = (options: {
|
|
145
|
+
entity: EntityRef<TEntity>
|
|
146
|
+
result: FileUploadResult
|
|
147
|
+
}) => void
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* File data extractor interface for bindx.
|
|
151
|
+
* Provides field names for selection collection and data extraction/population methods.
|
|
152
|
+
*/
|
|
153
|
+
export interface FileDataExtractor<TEntity = Record<string, unknown>> {
|
|
154
|
+
/**
|
|
155
|
+
* Returns the field names this extractor will populate.
|
|
156
|
+
* Used by the selection system to know which fields to fetch.
|
|
157
|
+
*/
|
|
158
|
+
getFieldNames: () => FieldName<TEntity>[]
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract data from the file (async, e.g., image dimensions)
|
|
162
|
+
*/
|
|
163
|
+
extractFileData?: (file: FileWithMeta) => Promise<FileDataExtractorPopulator<TEntity> | undefined> | FileDataExtractorPopulator<TEntity> | undefined
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Populate entity fields synchronously (e.g., URL field from upload result)
|
|
167
|
+
*/
|
|
168
|
+
populateFields?: (options: { entity: EntityRef<TEntity>; result: FileUploadResult }) => void
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// File Type Definition
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Configuration for a file type that defines how files are handled.
|
|
177
|
+
*/
|
|
178
|
+
export interface FileType<TEntity = Record<string, unknown>> {
|
|
179
|
+
/**
|
|
180
|
+
* Accepted MIME types and extensions.
|
|
181
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker#accept
|
|
182
|
+
* undefined means "any mime type"
|
|
183
|
+
*/
|
|
184
|
+
accept?: Record<string, string[]> | undefined
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Custom file validation. Optionally reject with rejection reason.
|
|
188
|
+
*/
|
|
189
|
+
acceptFile?: ((file: FileWithMeta) => boolean | Promise<void>) | undefined
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Data extractors for this file type
|
|
193
|
+
*/
|
|
194
|
+
extractors?: FileDataExtractor<TEntity>[]
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Custom upload client (overrides default)
|
|
198
|
+
*/
|
|
199
|
+
uploader?: UploadClient<unknown>
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* File type with optional base field for discriminated uploads.
|
|
204
|
+
*/
|
|
205
|
+
export interface DiscriminatedFileType<TEntity = Record<string, unknown>> extends FileType<TEntity> {
|
|
206
|
+
/**
|
|
207
|
+
* Base field name for has-one relation (e.g., 'image' for article.image)
|
|
208
|
+
*/
|
|
209
|
+
baseField?: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Map of discriminator values to file types for polymorphic file handling.
|
|
214
|
+
*/
|
|
215
|
+
export type DiscriminatedFileTypeMap<TEntity = Record<string, unknown>> = Record<string, DiscriminatedFileType<TEntity>>
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// Options Types
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
export interface UploaderOptions {
|
|
222
|
+
accept?: Record<string, string[]>
|
|
223
|
+
multiple: boolean
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Error Types
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
export type UploaderErrorType =
|
|
231
|
+
| 'fileRejected'
|
|
232
|
+
| 'networkError'
|
|
233
|
+
| 'httpError'
|
|
234
|
+
| 'aborted'
|
|
235
|
+
| 'timeout'
|
|
236
|
+
|
|
237
|
+
export interface UploaderErrorOptions {
|
|
238
|
+
type: UploaderErrorType
|
|
239
|
+
endUserMessage?: string
|
|
240
|
+
developerMessage?: string
|
|
241
|
+
error?: unknown
|
|
242
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { UploadClient, UploadClientUploadArgs } from '../types.js'
|
|
2
|
+
import type { S3FileParameters, S3SignedUrlResponse, S3UrlSigner } from './types.js'
|
|
3
|
+
import { UploaderError } from '../UploaderError.js'
|
|
4
|
+
|
|
5
|
+
export interface S3UploadClientOptions {
|
|
6
|
+
signUrl: S3UrlSigner
|
|
7
|
+
getUploadOptions?: (file: File) => Partial<S3FileParameters>
|
|
8
|
+
concurrency?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type S3FileOptions = Partial<S3FileParameters>
|
|
12
|
+
|
|
13
|
+
export class S3UploadClient implements UploadClient<S3FileOptions> {
|
|
14
|
+
private activeCount = 0
|
|
15
|
+
private resolverQueue: Array<() => void> = []
|
|
16
|
+
|
|
17
|
+
public constructor(public readonly options: S3UploadClientOptions) {}
|
|
18
|
+
|
|
19
|
+
public async upload({
|
|
20
|
+
file,
|
|
21
|
+
signal,
|
|
22
|
+
onProgress,
|
|
23
|
+
...options
|
|
24
|
+
}: UploadClientUploadArgs & S3FileOptions): Promise<{ publicUrl: string }> {
|
|
25
|
+
const parameters: S3FileParameters = {
|
|
26
|
+
contentType: file.type,
|
|
27
|
+
...this.options.getUploadOptions?.(file),
|
|
28
|
+
...options,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const signedUrl = await this.options.signUrl({ ...parameters, file })
|
|
32
|
+
await this.uploadSingleFile(signedUrl, { file, onProgress, signal })
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
publicUrl: signedUrl.publicUrl,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async uploadSingleFile(
|
|
40
|
+
signedUrl: S3SignedUrlResponse,
|
|
41
|
+
options: UploadClientUploadArgs,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
if (this.activeCount >= (this.options.concurrency ?? 5)) {
|
|
45
|
+
await new Promise<void>(resolve => this.resolverQueue.push(resolve))
|
|
46
|
+
}
|
|
47
|
+
this.activeCount++
|
|
48
|
+
|
|
49
|
+
await xhrAdapter(signedUrl, options)
|
|
50
|
+
} finally {
|
|
51
|
+
this.activeCount--
|
|
52
|
+
this.resolverQueue.shift()?.()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const xhrAdapter = async (
|
|
58
|
+
signedUrl: S3SignedUrlResponse,
|
|
59
|
+
{ file, signal, onProgress }: UploadClientUploadArgs,
|
|
60
|
+
): Promise<void> => {
|
|
61
|
+
return await new Promise<void>((resolve, reject) => {
|
|
62
|
+
const xhr = new XMLHttpRequest()
|
|
63
|
+
|
|
64
|
+
signal.addEventListener('abort', () => {
|
|
65
|
+
xhr.abort()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
xhr.open(signedUrl.method, signedUrl.url)
|
|
69
|
+
|
|
70
|
+
for (const header of signedUrl.headers) {
|
|
71
|
+
xhr.setRequestHeader(header.key, header.value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
xhr.addEventListener('load', () => {
|
|
75
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
76
|
+
resolve()
|
|
77
|
+
} else {
|
|
78
|
+
reject(
|
|
79
|
+
new UploaderError({
|
|
80
|
+
type: 'httpError',
|
|
81
|
+
developerMessage: `HTTP error: ${xhr.status}`,
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
xhr.addEventListener('error', () => {
|
|
88
|
+
reject(
|
|
89
|
+
new UploaderError({
|
|
90
|
+
type: 'networkError',
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
xhr.addEventListener('abort', () => {
|
|
95
|
+
reject(
|
|
96
|
+
new UploaderError({
|
|
97
|
+
type: 'aborted',
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
xhr.addEventListener('timeout', () => {
|
|
102
|
+
reject(
|
|
103
|
+
new UploaderError({
|
|
104
|
+
type: 'timeout',
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
xhr.upload?.addEventListener('progress', e => {
|
|
110
|
+
onProgress({
|
|
111
|
+
totalBytes: e.total,
|
|
112
|
+
uploadedBytes: e.loaded,
|
|
113
|
+
progress: e.loaded / e.total,
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
xhr.send(file)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 upload URL signing types.
|
|
3
|
+
* These types mirror @contember/client's GenerateUploadUrlMutationBuilder types
|
|
4
|
+
* but are self-contained for bindx-uploader.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type S3Acl = 'PUBLIC_READ' | 'PRIVATE' | 'NONE'
|
|
8
|
+
|
|
9
|
+
export interface S3FileParameters {
|
|
10
|
+
contentType: string
|
|
11
|
+
expiration?: number
|
|
12
|
+
size?: number
|
|
13
|
+
prefix?: string
|
|
14
|
+
extension?: string
|
|
15
|
+
suffix?: string
|
|
16
|
+
fileName?: string
|
|
17
|
+
acl?: S3Acl
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface S3SignedUrlResponse {
|
|
21
|
+
url: string
|
|
22
|
+
publicUrl: string
|
|
23
|
+
method: string
|
|
24
|
+
headers: Array<{
|
|
25
|
+
key: string
|
|
26
|
+
value: string
|
|
27
|
+
}>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type S3UrlSigner = (args: S3FileParameters & { file: File }) => Promise<S3SignedUrlResponse>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a file type matches an accept specification.
|
|
3
|
+
* Ported from react-dropzone's attr-accept utility.
|
|
4
|
+
*
|
|
5
|
+
* @param file - File or object with type and name properties
|
|
6
|
+
* @param accept - Accept specification (MIME type, extension, or wildcard)
|
|
7
|
+
*/
|
|
8
|
+
export function attrAccept(
|
|
9
|
+
file: { type?: string; name?: string },
|
|
10
|
+
accept?: string | string[] | Record<string, string[]>,
|
|
11
|
+
): boolean {
|
|
12
|
+
if (!accept) {
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const acceptArray = normalizeAccept(accept)
|
|
17
|
+
|
|
18
|
+
if (acceptArray.length === 0) {
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const mimeType = (file.type || '').toLowerCase()
|
|
23
|
+
const baseMimeType = mimeType.replace(/\/.*$/, '')
|
|
24
|
+
const fileName = file.name || ''
|
|
25
|
+
const dotIndex = fileName.lastIndexOf('.')
|
|
26
|
+
const extension = dotIndex >= 0 ? fileName.toLowerCase().slice(dotIndex + 1) : ''
|
|
27
|
+
|
|
28
|
+
return acceptArray.some(type => {
|
|
29
|
+
const normalizedType = type.trim().toLowerCase()
|
|
30
|
+
|
|
31
|
+
// Extension match (e.g., ".png")
|
|
32
|
+
if (normalizedType.startsWith('.')) {
|
|
33
|
+
return extension === normalizedType.slice(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Wildcard MIME type (e.g., "image/*")
|
|
37
|
+
if (normalizedType.endsWith('/*')) {
|
|
38
|
+
return baseMimeType === normalizedType.replace(/\/\*$/, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Exact MIME type match
|
|
42
|
+
return mimeType === normalizedType
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize accept specification to an array of strings.
|
|
48
|
+
*/
|
|
49
|
+
function normalizeAccept(accept: string | string[] | Record<string, string[]>): string[] {
|
|
50
|
+
if (typeof accept === 'string') {
|
|
51
|
+
return accept.split(',').map(s => s.trim())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(accept)) {
|
|
55
|
+
return accept
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Record<mimeType, extensions[]>
|
|
59
|
+
const result: string[] = []
|
|
60
|
+
for (const mimeType in accept) {
|
|
61
|
+
result.push(mimeType)
|
|
62
|
+
const extensions = accept[mimeType]
|
|
63
|
+
if (extensions) {
|
|
64
|
+
result.push(...extensions)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert accept record to a flat string for input accept attribute.
|
|
72
|
+
*/
|
|
73
|
+
export function acceptToString(accept?: Record<string, string[]>): string | undefined {
|
|
74
|
+
if (!accept) {
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result: string[] = []
|
|
79
|
+
for (const mimeType in accept) {
|
|
80
|
+
result.push(mimeType)
|
|
81
|
+
const extensions = accept[mimeType]
|
|
82
|
+
if (extensions) {
|
|
83
|
+
result.push(...extensions)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result.join(',')
|
|
87
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { GraphQlClient } from '@contember/graphql-client'
|
|
2
|
+
import type { S3FileParameters, S3SignedUrlResponse } from '../uploadClient/types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates an S3 URL signer that batches requests to reduce API calls.
|
|
6
|
+
* Uses microtask scheduling to batch concurrent signing requests into a single GraphQL mutation.
|
|
7
|
+
*/
|
|
8
|
+
export const createContentApiS3Signer = (
|
|
9
|
+
client: GraphQlClient,
|
|
10
|
+
): ((parameters: S3FileParameters) => Promise<S3SignedUrlResponse>) => {
|
|
11
|
+
let uploadUrlBatchParameters: S3FileParameters[] = []
|
|
12
|
+
let uploadUrlBatchResult: null | Promise<Record<string, S3SignedUrlResponse>> = null
|
|
13
|
+
|
|
14
|
+
return async (parameters: S3FileParameters): Promise<S3SignedUrlResponse> => {
|
|
15
|
+
const index = uploadUrlBatchParameters.length
|
|
16
|
+
uploadUrlBatchParameters.push(parameters)
|
|
17
|
+
|
|
18
|
+
if (uploadUrlBatchResult === null) {
|
|
19
|
+
uploadUrlBatchResult = (async () => {
|
|
20
|
+
// Wait for microtask to batch concurrent requests
|
|
21
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
22
|
+
|
|
23
|
+
const currentParams = uploadUrlBatchParameters
|
|
24
|
+
uploadUrlBatchResult = null
|
|
25
|
+
uploadUrlBatchParameters = []
|
|
26
|
+
|
|
27
|
+
const mutation = buildGenerateUploadUrlMutation(
|
|
28
|
+
Object.fromEntries(currentParams.map((p, i) => [`url_${i}`, p])),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return await client.execute<Record<string, S3SignedUrlResponse>>(mutation.query, {
|
|
32
|
+
variables: mutation.variables,
|
|
33
|
+
})
|
|
34
|
+
})()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result = (await uploadUrlBatchResult)[`url_${index}`]
|
|
38
|
+
if (!result) {
|
|
39
|
+
throw new Error(`Failed to get signed URL for index ${index}`)
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds a GraphQL mutation for generating S3 upload URLs.
|
|
47
|
+
*/
|
|
48
|
+
function buildGenerateUploadUrlMutation(
|
|
49
|
+
parameters: Record<string, S3FileParameters>,
|
|
50
|
+
): { query: string; variables: Record<string, unknown> } {
|
|
51
|
+
const fields: string[] = []
|
|
52
|
+
const variables: Record<string, unknown> = {}
|
|
53
|
+
const variableDefinitions: string[] = []
|
|
54
|
+
|
|
55
|
+
let varIndex = 0
|
|
56
|
+
for (const alias in parameters) {
|
|
57
|
+
const params = parameters[alias]
|
|
58
|
+
if (!params) continue
|
|
59
|
+
|
|
60
|
+
const hasNewFormat = params.suffix || params.fileName || params.extension
|
|
61
|
+
|
|
62
|
+
if (hasNewFormat) {
|
|
63
|
+
const inputVarName = `input_${varIndex}`
|
|
64
|
+
variableDefinitions.push(`$${inputVarName}: S3GenerateSignedUploadInput`)
|
|
65
|
+
variables[inputVarName] = {
|
|
66
|
+
contentType: params.contentType,
|
|
67
|
+
prefix: params.prefix,
|
|
68
|
+
expiration: params.expiration,
|
|
69
|
+
acl: params.acl,
|
|
70
|
+
size: params.size,
|
|
71
|
+
suffix: params.suffix,
|
|
72
|
+
fileName: params.fileName,
|
|
73
|
+
extension: params.extension,
|
|
74
|
+
}
|
|
75
|
+
fields.push(`${alias}: generateUploadUrl(input: $${inputVarName}) {
|
|
76
|
+
url
|
|
77
|
+
publicUrl
|
|
78
|
+
method
|
|
79
|
+
headers { key value }
|
|
80
|
+
}`)
|
|
81
|
+
} else {
|
|
82
|
+
const contentTypeVar = `contentType_${varIndex}`
|
|
83
|
+
const expirationVar = `expiration_${varIndex}`
|
|
84
|
+
const prefixVar = `prefix_${varIndex}`
|
|
85
|
+
const aclVar = `acl_${varIndex}`
|
|
86
|
+
|
|
87
|
+
variableDefinitions.push(`$${contentTypeVar}: String`)
|
|
88
|
+
variableDefinitions.push(`$${expirationVar}: Int`)
|
|
89
|
+
variableDefinitions.push(`$${prefixVar}: String`)
|
|
90
|
+
variableDefinitions.push(`$${aclVar}: S3Acl`)
|
|
91
|
+
|
|
92
|
+
variables[contentTypeVar] = params.contentType
|
|
93
|
+
variables[expirationVar] = params.expiration
|
|
94
|
+
variables[prefixVar] = params.prefix
|
|
95
|
+
variables[aclVar] = params.acl
|
|
96
|
+
|
|
97
|
+
fields.push(`${alias}: generateUploadUrl(
|
|
98
|
+
contentType: $${contentTypeVar}
|
|
99
|
+
expiration: $${expirationVar}
|
|
100
|
+
prefix: $${prefixVar}
|
|
101
|
+
acl: $${aclVar}
|
|
102
|
+
) {
|
|
103
|
+
url
|
|
104
|
+
publicUrl
|
|
105
|
+
method
|
|
106
|
+
headers { key value }
|
|
107
|
+
}`)
|
|
108
|
+
}
|
|
109
|
+
varIndex++
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const query = `mutation${variableDefinitions.length > 0 ? `(${variableDefinitions.join(', ')})` : ''} {
|
|
113
|
+
${fields.join('\n')}
|
|
114
|
+
}`
|
|
115
|
+
|
|
116
|
+
return { query, variables }
|
|
117
|
+
}
|