@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.
Files changed (127) hide show
  1. package/dist/UploaderError.d.ts +6 -0
  2. package/dist/UploaderError.d.ts.map +1 -0
  3. package/dist/components/MultiUploader.d.ts +43 -0
  4. package/dist/components/MultiUploader.d.ts.map +1 -0
  5. package/dist/components/Uploader.d.ts +58 -0
  6. package/dist/components/Uploader.d.ts.map +1 -0
  7. package/dist/components/UploaderDropzoneArea.d.ts +6 -0
  8. package/dist/components/UploaderDropzoneArea.d.ts.map +1 -0
  9. package/dist/components/UploaderDropzoneRoot.d.ts +8 -0
  10. package/dist/components/UploaderDropzoneRoot.d.ts.map +1 -0
  11. package/dist/components/UploaderEachFile.d.ts +14 -0
  12. package/dist/components/UploaderEachFile.d.ts.map +1 -0
  13. package/dist/components/UploaderFileStateSwitch.d.ts +24 -0
  14. package/dist/components/UploaderFileStateSwitch.d.ts.map +1 -0
  15. package/dist/components/UploaderHasFile.d.ts +14 -0
  16. package/dist/components/UploaderHasFile.d.ts.map +1 -0
  17. package/dist/components/index.d.ts +8 -0
  18. package/dist/components/index.d.ts.map +1 -0
  19. package/dist/contexts.d.ts +32 -0
  20. package/dist/contexts.d.ts.map +1 -0
  21. package/dist/extractors/getAudioFileDataExtractor.d.ts +9 -0
  22. package/dist/extractors/getAudioFileDataExtractor.d.ts.map +1 -0
  23. package/dist/extractors/getFileUrlDataExtractor.d.ts +9 -0
  24. package/dist/extractors/getFileUrlDataExtractor.d.ts.map +1 -0
  25. package/dist/extractors/getGenericFileMetadataExtractor.d.ts +12 -0
  26. package/dist/extractors/getGenericFileMetadataExtractor.d.ts.map +1 -0
  27. package/dist/extractors/getImageFileDataExtractor.d.ts +10 -0
  28. package/dist/extractors/getImageFileDataExtractor.d.ts.map +1 -0
  29. package/dist/extractors/getVideoFileDataExtractor.d.ts +11 -0
  30. package/dist/extractors/getVideoFileDataExtractor.d.ts.map +1 -0
  31. package/dist/extractors/index.d.ts +6 -0
  32. package/dist/extractors/index.d.ts.map +1 -0
  33. package/dist/extractors/types.d.ts +13 -0
  34. package/dist/extractors/types.d.ts.map +1 -0
  35. package/dist/fileTypes/createAnyFileType.d.ts +27 -0
  36. package/dist/fileTypes/createAnyFileType.d.ts.map +1 -0
  37. package/dist/fileTypes/createAudioFileType.d.ts +29 -0
  38. package/dist/fileTypes/createAudioFileType.d.ts.map +1 -0
  39. package/dist/fileTypes/createImageFileType.d.ts +47 -0
  40. package/dist/fileTypes/createImageFileType.d.ts.map +1 -0
  41. package/dist/fileTypes/createVideoFileType.d.ts +33 -0
  42. package/dist/fileTypes/createVideoFileType.d.ts.map +1 -0
  43. package/dist/fileTypes/index.d.ts +5 -0
  44. package/dist/fileTypes/index.d.ts.map +1 -0
  45. package/dist/hooks/index.d.ts +3 -0
  46. package/dist/hooks/index.d.ts.map +1 -0
  47. package/dist/hooks/useS3Client.d.ts +7 -0
  48. package/dist/hooks/useS3Client.d.ts.map +1 -0
  49. package/dist/hooks/useUploaderStateFiles.d.ts +8 -0
  50. package/dist/hooks/useUploaderStateFiles.d.ts.map +1 -0
  51. package/dist/index.d.ts +10 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/internal/hooks/index.d.ts +5 -0
  54. package/dist/internal/hooks/index.d.ts.map +1 -0
  55. package/dist/internal/hooks/useFillEntity.d.ts +17 -0
  56. package/dist/internal/hooks/useFillEntity.d.ts.map +1 -0
  57. package/dist/internal/hooks/useGetPreviewUrls.d.ts +6 -0
  58. package/dist/internal/hooks/useGetPreviewUrls.d.ts.map +1 -0
  59. package/dist/internal/hooks/useUploadState.d.ts +12 -0
  60. package/dist/internal/hooks/useUploadState.d.ts.map +1 -0
  61. package/dist/internal/hooks/useUploaderDoUpload.d.ts +7 -0
  62. package/dist/internal/hooks/useUploaderDoUpload.d.ts.map +1 -0
  63. package/dist/internal/utils/executeExtractors.d.ts +16 -0
  64. package/dist/internal/utils/executeExtractors.d.ts.map +1 -0
  65. package/dist/internal/utils/index.d.ts +4 -0
  66. package/dist/internal/utils/index.d.ts.map +1 -0
  67. package/dist/internal/utils/resolveAccept.d.ts +7 -0
  68. package/dist/internal/utils/resolveAccept.d.ts.map +1 -0
  69. package/dist/internal/utils/uploaderErrorHandler.d.ts +7 -0
  70. package/dist/internal/utils/uploaderErrorHandler.d.ts.map +1 -0
  71. package/dist/types.d.ts +168 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/uploadClient/S3UploadClient.d.ts +19 -0
  74. package/dist/uploadClient/S3UploadClient.d.ts.map +1 -0
  75. package/dist/uploadClient/index.d.ts +3 -0
  76. package/dist/uploadClient/index.d.ts.map +1 -0
  77. package/dist/uploadClient/types.d.ts +29 -0
  78. package/dist/uploadClient/types.d.ts.map +1 -0
  79. package/dist/utils/attrAccept.d.ts +16 -0
  80. package/dist/utils/attrAccept.d.ts.map +1 -0
  81. package/dist/utils/index.d.ts +3 -0
  82. package/dist/utils/index.d.ts.map +1 -0
  83. package/dist/utils/urlSigner.d.ts +8 -0
  84. package/dist/utils/urlSigner.d.ts.map +1 -0
  85. package/package.json +35 -0
  86. package/src/UploaderError.ts +7 -0
  87. package/src/components/MultiUploader.tsx +210 -0
  88. package/src/components/Uploader.tsx +174 -0
  89. package/src/components/UploaderDropzoneArea.tsx +29 -0
  90. package/src/components/UploaderDropzoneRoot.tsx +32 -0
  91. package/src/components/UploaderEachFile.tsx +46 -0
  92. package/src/components/UploaderFileStateSwitch.tsx +66 -0
  93. package/src/components/UploaderHasFile.tsx +35 -0
  94. package/src/components/index.ts +7 -0
  95. package/src/contexts.ts +76 -0
  96. package/src/extractors/getAudioFileDataExtractor.ts +44 -0
  97. package/src/extractors/getFileUrlDataExtractor.ts +22 -0
  98. package/src/extractors/getGenericFileMetadataExtractor.ts +40 -0
  99. package/src/extractors/getImageFileDataExtractor.ts +50 -0
  100. package/src/extractors/getVideoFileDataExtractor.ts +60 -0
  101. package/src/extractors/index.ts +5 -0
  102. package/src/extractors/types.ts +14 -0
  103. package/src/fileTypes/createAnyFileType.ts +56 -0
  104. package/src/fileTypes/createAudioFileType.ts +67 -0
  105. package/src/fileTypes/createImageFileType.ts +87 -0
  106. package/src/fileTypes/createVideoFileType.ts +75 -0
  107. package/src/fileTypes/index.ts +4 -0
  108. package/src/hooks/index.ts +2 -0
  109. package/src/hooks/useS3Client.ts +26 -0
  110. package/src/hooks/useUploaderStateFiles.ts +22 -0
  111. package/src/index.ts +126 -0
  112. package/src/internal/hooks/index.ts +4 -0
  113. package/src/internal/hooks/useFillEntity.ts +89 -0
  114. package/src/internal/hooks/useGetPreviewUrls.ts +26 -0
  115. package/src/internal/hooks/useUploadState.ts +159 -0
  116. package/src/internal/hooks/useUploaderDoUpload.ts +129 -0
  117. package/src/internal/utils/executeExtractors.ts +46 -0
  118. package/src/internal/utils/index.ts +3 -0
  119. package/src/internal/utils/resolveAccept.ts +26 -0
  120. package/src/internal/utils/uploaderErrorHandler.ts +15 -0
  121. package/src/types.ts +242 -0
  122. package/src/uploadClient/S3UploadClient.ts +119 -0
  123. package/src/uploadClient/index.ts +2 -0
  124. package/src/uploadClient/types.ts +30 -0
  125. package/src/utils/attrAccept.ts +87 -0
  126. package/src/utils/index.ts +2 -0
  127. 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,2 @@
1
+ export { S3UploadClient, type S3UploadClientOptions, type S3FileOptions } from './S3UploadClient.js'
2
+ export type { S3Acl, S3FileParameters, S3SignedUrlResponse, S3UrlSigner } from './types.js'
@@ -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,2 @@
1
+ export { createContentApiS3Signer } from './urlSigner.js'
2
+ export { attrAccept, acceptToString } from './attrAccept.js'
@@ -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
+ }