transloadit 6.7.0 → 6.9.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.
@@ -0,0 +1,271 @@
1
+ import type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts'
2
+ import type { InterpolatableRobotUploadHandleInstructionsInput } from './alphalib/types/robots/upload-handle.ts'
3
+ import type { StoredAsset } from './alphalib/types/storageAsset.ts'
4
+ import type { CreateAssemblyOptions, Transloadit } from './Transloadit.ts'
5
+
6
+ import { createHash } from 'node:crypto'
7
+ import { createReadStream } from 'node:fs'
8
+
9
+ import debug from 'debug'
10
+ import { z } from 'zod'
11
+
12
+ import { ApiError } from './ApiError.ts'
13
+ import { storedAssetSchema } from './alphalib/types/storageAsset.ts'
14
+ import InconsistentResponseError from './InconsistentResponseError.ts'
15
+
16
+ /**
17
+ * Verified metadata for an original of any media type. Dimensions are optional.
18
+ */
19
+ export interface StoredAssetReceipt extends Readonly<StoredAsset> {
20
+ /** Legacy catalog spelling; new server receipts use has_alpha. */
21
+ readonly hasAlpha?: boolean
22
+ readonly md5hash: string
23
+ }
24
+
25
+ /** Shared SDK/CLI opt-in for server extraction; rendering remains a separate choice. */
26
+ export const storageImagePlaceholderSchema = z.enum(['blur', 'empty'])
27
+
28
+ /** One explicit destination, with the existing Assembly upload and polling controls. */
29
+ export interface StoreAssetOptions
30
+ extends Pick<
31
+ CreateAssemblyOptions,
32
+ 'chunkSize' | 'onAssemblyProgress' | 'onUploadProgress' | 'signal' | 'timeout'
33
+ > {
34
+ /** Complete relative Storage filename. Directories and interpolation expressions are rejected. */
35
+ path: string
36
+ /** Explicit opt-in replacement of an existing path; defaults to false. */
37
+ overwrite?: boolean
38
+ /** Request a server ThumbHash. Successful extraction adds metadata usage of 20% of the file bytes. */
39
+ placeholder?: z.infer<typeof storageImagePlaceholderSchema>
40
+ /** Observe verified metadata. Not awaited; sync and async observer errors cannot undo a write. */
41
+ onReceipt?: (
42
+ receipt: StoredAssetReceipt,
43
+ input: StoredAssetExpectation,
44
+ assemblyId: string | undefined,
45
+ ) => void | Promise<void>
46
+ }
47
+
48
+ /** Trusted upload facts used to correlate a stored result with an application-owned upload. */
49
+ export interface StoredAssetExpectation {
50
+ path: string
51
+ size: number
52
+ md5hash: string
53
+ }
54
+
55
+ /** Recovers one original's receipt without re-uploading or trusting notification payloads. */
56
+ export interface GetStoredAssetReceiptOptions {
57
+ assemblyId: string
58
+ expected: StoredAssetExpectation
59
+ }
60
+
61
+ const positiveIntegerSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
62
+ const receiptRequestSchema = z.object({
63
+ assemblyId: z.string().regex(/^[A-Za-z0-9_-]+$/),
64
+ expected: z.object({
65
+ path: z.string(),
66
+ size: positiveIntegerSchema,
67
+ md5hash: z.string().regex(/^[a-f0-9]{32}$/),
68
+ }),
69
+ })
70
+ const completedAssetSchema = z.object({
71
+ ok: z.literal('ASSEMBLY_COMPLETED'),
72
+ results: z.object({
73
+ ':original': z.tuple([
74
+ storedAssetSchema.extend({
75
+ md5hash: storedAssetSchema.shape.md5hash.unwrap(),
76
+ size: positiveIntegerSchema,
77
+ }),
78
+ ]),
79
+ }),
80
+ })
81
+ const completedImageSchema = completedAssetSchema.extend({
82
+ results: z.object({
83
+ ':original': z.tuple([
84
+ completedAssetSchema.shape.results.shape[':original'].items[0].extend({
85
+ width: positiveIntegerSchema,
86
+ height: positiveIntegerSchema,
87
+ }),
88
+ ]),
89
+ }),
90
+ })
91
+
92
+ function validateDestination(path: string): void {
93
+ if (
94
+ typeof path !== 'string' ||
95
+ path.trim() !== path ||
96
+ Buffer.byteLength(path) > 1024 ||
97
+ path.normalize('NFC') !== path ||
98
+ /[\p{Cc}\p{Cs}\\|]/u.test(path) ||
99
+ path.includes('${') ||
100
+ path.split('/').some((segment) => segment.trim() === '' || segment === '.' || segment === '..')
101
+ ) {
102
+ throw new TypeError('Storage path must be a complete relative filename without interpolation')
103
+ }
104
+ }
105
+
106
+ /** Stores one exact original; a mismatch is reported with the completed write's receipt. */
107
+ export function storeAsset(
108
+ client: Transloadit,
109
+ filePath: string,
110
+ options: StoreAssetOptions,
111
+ ): Promise<StoredAssetReceipt> {
112
+ return storeOriginal(client, filePath, options)
113
+ }
114
+
115
+ /** Shared upload pipeline; the image entry point retains its existing transformed-byte policy. */
116
+ export async function storeOriginal(
117
+ client: Transloadit,
118
+ filePath: string,
119
+ options: StoreAssetOptions,
120
+ image = false,
121
+ ): Promise<StoredAssetReceipt> {
122
+ const {
123
+ path,
124
+ chunkSize,
125
+ onAssemblyProgress,
126
+ onUploadProgress,
127
+ onReceipt,
128
+ overwrite,
129
+ signal,
130
+ timeout,
131
+ } = options
132
+ validateDestination(path)
133
+ const placeholder = storageImagePlaceholderSchema.optional().parse(options.placeholder, {
134
+ path: ['placeholder'],
135
+ })
136
+ if (overwrite !== undefined && typeof overwrite !== 'boolean')
137
+ throw new TypeError('overwrite must be a boolean')
138
+ signal?.throwIfAborted()
139
+ const checksum = createHash('md5')
140
+ let size = 0
141
+ for await (const chunk of createReadStream(filePath, { signal })) {
142
+ checksum.update(chunk)
143
+ size += chunk.length
144
+ }
145
+ if (size === 0)
146
+ throw new Error(image ? 'Cannot store an empty image' : 'Cannot store an empty file')
147
+ const md5hash = checksum.digest('hex')
148
+ signal?.throwIfAborted()
149
+ const assembly = await client.createAssembly({
150
+ chunkSize,
151
+ files: { [image ? 'image' : 'file']: filePath },
152
+ onAssemblyProgress,
153
+ onUploadProgress,
154
+ params: {
155
+ steps: {
156
+ ...(placeholder === 'blur'
157
+ ? {
158
+ ':original': {
159
+ robot: '/upload/handle',
160
+ output_meta: { thumbhash: true },
161
+ } satisfies InterpolatableRobotUploadHandleInstructionsInput,
162
+ }
163
+ : {}),
164
+ stored: {
165
+ robot: '/transloadit/store',
166
+ use: ':original',
167
+ path,
168
+ conflict_strategy: overwrite === true ? 'overwrite' : 'error',
169
+ },
170
+ },
171
+ },
172
+ signal,
173
+ timeout,
174
+ waitForCompletion: true,
175
+ })
176
+ const input = { path, size, md5hash }
177
+ // API2 can watermark uploads before Robots run; this exact write's result describes stored bytes.
178
+ const receipt = validateReceipt(assembly, input, true, image)
179
+ const observerFailed = (): void => {
180
+ debug('transloadit:warn')('Ignored onReceipt observer failure after a completed Storage write')
181
+ }
182
+ try {
183
+ // Neither a stalled observer nor its rejection may hide a successfully stored receipt.
184
+ void Promise.resolve(onReceipt?.({ ...receipt }, input, assembly.assembly_id)).catch(
185
+ observerFailed,
186
+ )
187
+ } catch {
188
+ observerFailed()
189
+ }
190
+ if (!image && (receipt.size !== size || receipt.md5hash !== md5hash)) {
191
+ throw new InconsistentResponseError(
192
+ 'Stored bytes differ from the original file; do not upload again',
193
+ {
194
+ cause: { assemblyId: assembly.assembly_id, receipt, expected: input },
195
+ },
196
+ )
197
+ }
198
+ return receipt
199
+ }
200
+
201
+ /** Fetches authoritative Assembly status and verifies identity and original-byte fidelity. */
202
+ export function getStoredAssetReceipt(
203
+ client: Transloadit,
204
+ options: GetStoredAssetReceiptOptions,
205
+ ): Promise<StoredAssetReceipt> {
206
+ return getOriginalReceipt(client, options)
207
+ }
208
+
209
+ /** Shared recovery keeps image-specific validation and Assembly diagnostics together. */
210
+ export async function getOriginalReceipt(
211
+ client: Transloadit,
212
+ options: GetStoredAssetReceiptOptions,
213
+ image = false,
214
+ ): Promise<StoredAssetReceipt> {
215
+ const { assemblyId, expected } = receiptRequestSchema.parse(options)
216
+ validateDestination(expected.path)
217
+ const assembly = await client.getAssembly(assemblyId)
218
+ if (assembly.assembly_id !== assemblyId) {
219
+ throw new InconsistentResponseError('The response did not match the requested Assembly', {
220
+ cause: { assemblyId },
221
+ })
222
+ }
223
+ return validateReceipt(assembly, expected, false, image)
224
+ }
225
+
226
+ function validateReceipt(
227
+ assembly: AssemblyStatus,
228
+ expected: StoredAssetExpectation,
229
+ acceptTransformed = false,
230
+ image = false,
231
+ ): StoredAssetReceipt {
232
+ const { path, size, md5hash } = expected
233
+ if (typeof assembly.error === 'string') throw new ApiError({ body: assembly })
234
+ if (assembly.ok === 'ASSEMBLY_CANCELED') {
235
+ throw new InconsistentResponseError('The Storage Assembly ended with ASSEMBLY_CANCELED', {
236
+ cause: { assemblyId: assembly.assembly_id },
237
+ })
238
+ }
239
+ if (
240
+ assembly.ok === 'ASSEMBLY_UPLOADING' ||
241
+ assembly.ok === 'ASSEMBLY_EXECUTING' ||
242
+ assembly.ok === 'ASSEMBLY_REPLAYING'
243
+ ) {
244
+ throw new InconsistentResponseError(`The Storage Assembly is not complete (${assembly.ok})`, {
245
+ cause: { assemblyId: assembly.assembly_id },
246
+ })
247
+ }
248
+ const parsed = (image ? completedImageSchema : completedAssetSchema).safeParse(assembly)
249
+ const result = parsed.success ? parsed.data.results[':original'][0] : undefined
250
+ const originals = assembly.results?.[':original']
251
+ const receiptCheck = {
252
+ originalCount: Array.isArray(originals) ? originals.length : 0,
253
+ metadataValid: parsed.success,
254
+ pathMatches: result === undefined ? undefined : result.path === path,
255
+ sizeMatches: result === undefined ? undefined : result.size === size,
256
+ md5Matches: result === undefined ? undefined : result.md5hash === md5hash,
257
+ }
258
+ if (
259
+ result === undefined ||
260
+ result.path !== path ||
261
+ (!acceptTransformed && (!receiptCheck.sizeMatches || !receiptCheck.md5Matches))
262
+ ) {
263
+ throw new InconsistentResponseError(
264
+ `The Assembly did not return a matching Storage ${image ? 'image' : 'asset'} receipt`,
265
+ {
266
+ cause: { assemblyId: assembly.assembly_id, receiptCheck },
267
+ },
268
+ )
269
+ }
270
+ return result
271
+ }
@@ -1,46 +1,30 @@
1
- import type { AssemblyStatus } from './alphalib/types/assemblyStatus.ts'
2
- import type { InterpolatableRobotUploadHandleInstructionsInput } from './alphalib/types/robots/upload-handle.ts'
3
- import type { StoredAsset } from './alphalib/types/storageAsset.ts'
4
- import type { CreateAssemblyOptions, Transloadit } from './Transloadit.ts'
1
+ import type {
2
+ GetStoredAssetReceiptOptions,
3
+ StoreAssetOptions,
4
+ StoredAssetExpectation,
5
+ StoredAssetReceipt,
6
+ } from './storageAsset.ts'
7
+ import type { Transloadit } from './Transloadit.ts'
5
8
 
6
- import { createHash } from 'node:crypto'
7
- import { createReadStream } from 'node:fs'
8
-
9
- import debug from 'debug'
10
9
  import { z } from 'zod'
11
10
 
12
- import { ApiError } from './ApiError.ts'
13
11
  import { storedAssetSchema } from './alphalib/types/storageAsset.ts'
14
12
  import InconsistentResponseError from './InconsistentResponseError.ts'
13
+ import { getOriginalReceipt, storeOriginal } from './storageAsset.ts'
14
+
15
+ export { storageImagePlaceholderSchema } from './storageAsset.ts'
15
16
 
16
- /**
17
- * Verified Storage metadata that can be saved and passed directly to an image renderer.
18
- * Width and height reflect EXIF auto-orientation, matching Storage preview delivery.
19
- */
20
- export interface StoredImageReceipt extends Readonly<StoredAsset> {
21
- /** Legacy catalog spelling; new server receipts use has_alpha. */
22
- readonly hasAlpha?: boolean
17
+ /** Verified image metadata; dimensions reflect EXIF orientation used by preview delivery. */
18
+ export interface StoredImageReceipt extends StoredAssetReceipt {
23
19
  readonly height: number
24
- readonly md5hash: string
25
20
  readonly width: number
26
21
  }
27
22
 
28
- /** Shared SDK/CLI opt-in for server extraction; rendering remains a separate choice. */
29
- export const storageImagePlaceholderSchema = z.enum(['blur', 'empty'])
23
+ export type StoredImageExpectation = StoredAssetExpectation
24
+ export type GetStoredImageReceiptOptions = GetStoredAssetReceiptOptions
30
25
 
31
- /** One explicit destination, with the existing Assembly upload and polling controls. */
32
- export interface StoreImageOptions
33
- extends Pick<
34
- CreateAssemblyOptions,
35
- 'chunkSize' | 'onAssemblyProgress' | 'onUploadProgress' | 'signal' | 'timeout'
36
- > {
37
- /** Complete relative Storage filename. Directories and interpolation expressions are rejected. */
38
- path: string
39
- /** Explicit opt-in replacement of an existing path; defaults to false. */
40
- overwrite?: boolean
41
- /** Request a server ThumbHash. Successful extraction adds metadata usage of 20% of the file bytes. */
42
- placeholder?: z.infer<typeof storageImagePlaceholderSchema>
43
- /** Observe verified metadata. Not awaited; sync and async observer errors cannot undo a write. */
26
+ /** Image-specific callback on the shared original upload pipeline. */
27
+ export interface StoreImageOptions extends Omit<StoreAssetOptions, 'onReceipt'> {
44
28
  onReceipt?: (
45
29
  receipt: StoredImageReceipt,
46
30
  input: StoredImageExpectation,
@@ -48,190 +32,48 @@ export interface StoreImageOptions
48
32
  ) => void | Promise<void>
49
33
  }
50
34
 
51
- /** Trusted upload facts used to correlate a stored result with an application-owned upload. */
52
- export interface StoredImageExpectation {
53
- path: string
54
- size: number
55
- md5hash: string
56
- }
57
-
58
- /** Recovers one original's receipt without re-uploading or trusting notification payloads. */
59
- export interface GetStoredImageReceiptOptions {
60
- assemblyId: string
61
- expected: StoredImageExpectation
62
- }
63
-
64
- const positiveIntegerSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
65
- const receiptRequestSchema = z.object({
66
- assemblyId: z.string().regex(/^[A-Za-z0-9_-]+$/),
67
- expected: z.object({
68
- path: z.string(),
69
- size: positiveIntegerSchema,
70
- md5hash: z.string().regex(/^[a-f0-9]{32}$/),
71
- }),
72
- })
73
- const completedImageSchema = z.object({
74
- ok: z.literal('ASSEMBLY_COMPLETED'),
75
- results: z.object({
76
- ':original': z.tuple([
77
- storedAssetSchema.extend({
78
- md5hash: storedAssetSchema.shape.md5hash.unwrap(),
79
- height: positiveIntegerSchema,
80
- width: positiveIntegerSchema,
81
- size: positiveIntegerSchema,
82
- }),
83
- ]),
84
- }),
35
+ const imageReceiptSchema = storedAssetSchema.extend({
36
+ md5hash: storedAssetSchema.shape.md5hash.unwrap(),
37
+ height: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
38
+ width: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
85
39
  })
86
40
 
87
- function validateDestination(path: string): void {
88
- if (
89
- typeof path !== 'string' ||
90
- path.trim() !== path ||
91
- Buffer.byteLength(path) > 1024 ||
92
- path.normalize('NFC') !== path ||
93
- /[\p{Cc}\p{Cs}\\|]/u.test(path) ||
94
- path.includes('${') ||
95
- path.split('/').some((segment) => segment.trim() === '' || segment === '.' || segment === '..')
96
- ) {
97
- throw new TypeError('Storage path must be a complete relative filename without interpolation')
41
+ function requireImage(receipt: StoredAssetReceipt): StoredImageReceipt {
42
+ const image = imageReceiptSchema.safeParse(receipt)
43
+ if (!image.success) {
44
+ throw new InconsistentResponseError(
45
+ 'The Assembly did not return a matching Storage image receipt',
46
+ {
47
+ cause: { receipt },
48
+ },
49
+ )
98
50
  }
51
+ return image.data
99
52
  }
100
53
 
101
- /** Stores one original without overwriting; receipt validation occurs after the Storage write. */
54
+ /** Retains the image API's transformed-byte semantics; use storeAsset for exact originals. */
102
55
  export async function storeImage(
103
56
  client: Transloadit,
104
57
  filePath: string,
105
58
  options: StoreImageOptions,
106
59
  ): Promise<StoredImageReceipt> {
107
- const {
108
- path,
109
- chunkSize,
110
- onAssemblyProgress,
111
- onUploadProgress,
112
- onReceipt,
113
- overwrite,
114
- signal,
115
- timeout,
116
- } = options
117
- validateDestination(path)
118
- const placeholder = storageImagePlaceholderSchema.optional().parse(options.placeholder, {
119
- path: ['placeholder'],
120
- })
121
- if (overwrite !== undefined && typeof overwrite !== 'boolean')
122
- throw new TypeError('overwrite must be a boolean')
123
- signal?.throwIfAborted()
124
- const checksum = createHash('md5')
125
- let size = 0
126
- for await (const chunk of createReadStream(filePath, { signal })) {
127
- checksum.update(chunk)
128
- size += chunk.length
129
- }
130
- if (size === 0) throw new Error('Cannot store an empty image')
131
- const md5hash = checksum.digest('hex')
132
- signal?.throwIfAborted()
133
- const assembly = await client.createAssembly({
134
- chunkSize,
135
- files: { image: filePath },
136
- onAssemblyProgress,
137
- onUploadProgress,
138
- params: {
139
- steps: {
140
- ...(placeholder === 'blur'
141
- ? {
142
- ':original': {
143
- robot: '/upload/handle',
144
- output_meta: { thumbhash: true },
145
- } satisfies InterpolatableRobotUploadHandleInstructionsInput,
146
- }
147
- : {}),
148
- stored: {
149
- robot: '/transloadit/store',
150
- use: ':original',
151
- path,
152
- conflict_strategy: overwrite === true ? 'overwrite' : 'error',
153
- },
154
- },
60
+ const receipt = await storeOriginal(
61
+ client,
62
+ filePath,
63
+ {
64
+ ...options,
65
+ onReceipt: (asset, input, assemblyId) =>
66
+ options.onReceipt?.(requireImage(asset), input, assemblyId),
155
67
  },
156
- signal,
157
- timeout,
158
- waitForCompletion: true,
159
- })
160
- const input = { path, size, md5hash }
161
- // API2 can watermark uploads before Robots run; this exact write's result describes stored bytes.
162
- const receipt = validateReceipt(assembly, input, true)
163
- const observerFailed = (): void => {
164
- debug('transloadit:warn')('Ignored onReceipt observer failure after a completed Storage write')
165
- }
166
- try {
167
- // Neither a stalled observer nor its rejection may hide a successfully stored receipt.
168
- void Promise.resolve(onReceipt?.({ ...receipt }, input, assembly.assembly_id)).catch(
169
- observerFailed,
170
- )
171
- } catch {
172
- observerFailed()
173
- }
174
- return receipt
68
+ true,
69
+ )
70
+ return requireImage(receipt)
175
71
  }
176
72
 
177
- /** Fetches authoritative Assembly status and applies the same validation as a local image store. */
73
+ /** Recovers one image receipt with the same strict byte checks as generic receipt recovery. */
178
74
  export async function getStoredImageReceipt(
179
75
  client: Transloadit,
180
76
  options: GetStoredImageReceiptOptions,
181
77
  ): Promise<StoredImageReceipt> {
182
- const { assemblyId, expected } = receiptRequestSchema.parse(options)
183
- validateDestination(expected.path)
184
- const assembly = await client.getAssembly(assemblyId)
185
- if (assembly.assembly_id !== assemblyId) {
186
- throw new InconsistentResponseError('The response did not match the requested Assembly', {
187
- cause: { assemblyId },
188
- })
189
- }
190
- return validateReceipt(assembly, expected)
191
- }
192
-
193
- function validateReceipt(
194
- assembly: AssemblyStatus,
195
- expected: StoredImageExpectation,
196
- acceptTransformed = false,
197
- ): StoredImageReceipt {
198
- const { path, size, md5hash } = expected
199
- if (typeof assembly.error === 'string') throw new ApiError({ body: assembly })
200
- if (assembly.ok === 'ASSEMBLY_CANCELED') {
201
- throw new InconsistentResponseError('The Storage Assembly ended with ASSEMBLY_CANCELED', {
202
- cause: { assemblyId: assembly.assembly_id },
203
- })
204
- }
205
- if (
206
- assembly.ok === 'ASSEMBLY_UPLOADING' ||
207
- assembly.ok === 'ASSEMBLY_EXECUTING' ||
208
- assembly.ok === 'ASSEMBLY_REPLAYING'
209
- ) {
210
- throw new InconsistentResponseError(`The Storage Assembly is not complete (${assembly.ok})`, {
211
- cause: { assemblyId: assembly.assembly_id },
212
- })
213
- }
214
- const parsed = completedImageSchema.safeParse(assembly)
215
- const result = parsed.success ? parsed.data.results[':original'][0] : undefined
216
- const originals = assembly.results?.[':original']
217
- const receiptCheck = {
218
- originalCount: Array.isArray(originals) ? originals.length : 0,
219
- metadataValid: parsed.success,
220
- pathMatches: result === undefined ? undefined : result.path === path,
221
- sizeMatches: result === undefined ? undefined : result.size === size,
222
- md5Matches: result === undefined ? undefined : result.md5hash === md5hash,
223
- }
224
- if (
225
- result === undefined ||
226
- result.path !== path ||
227
- (!acceptTransformed && (!receiptCheck.sizeMatches || !receiptCheck.md5Matches))
228
- ) {
229
- throw new InconsistentResponseError(
230
- 'The Assembly did not return a matching Storage image receipt',
231
- {
232
- cause: { assemblyId: assembly.assembly_id, receiptCheck },
233
- },
234
- )
235
- }
236
- return result
78
+ return requireImage(await getOriginalReceipt(client, options, true))
237
79
  }