@inoo-ch/payload-image-optimizer 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React from 'react'
4
4
  import { thumbHashToDataURL } from 'thumbhash'
5
- import { useAllFormFields, useDocumentInfo } from '@payloadcms/ui'
5
+ import { useAllFormFields } from '@payloadcms/ui'
6
6
 
7
7
  const formatBytes = (bytes: number): string => {
8
8
  if (bytes === 0) return '0 B'
@@ -19,90 +19,15 @@ const statusColors: Record<string, string> = {
19
19
  error: '#ef4444',
20
20
  }
21
21
 
22
- const POLL_INTERVAL_MS = 2000
23
-
24
- type PolledData = {
25
- status?: string
26
- originalSize?: number
27
- optimizedSize?: number
28
- thumbHash?: string
29
- error?: string
30
- variants?: Array<{
31
- format?: string
32
- filename?: string
33
- filesize?: number
34
- width?: number
35
- height?: number
36
- }>
37
- }
38
-
39
22
  export const OptimizationStatus: React.FC<{ path?: string }> = (props) => {
40
23
  const [formState] = useAllFormFields()
41
- const { collectionSlug, id } = useDocumentInfo()
42
24
  const basePath = props.path ?? 'imageOptimizer'
43
25
 
44
- const formStatus = formState[`${basePath}.status`]?.value as string | undefined
45
- const formOriginalSize = formState[`${basePath}.originalSize`]?.value as number | undefined
46
- const formOptimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined
47
- const formThumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined
48
- const formError = formState[`${basePath}.error`]?.value as string | undefined
49
-
50
- const [polledData, setPolledData] = React.useState<PolledData | null>(null)
51
-
52
- // Reset polled data when a new upload changes the form status back to pending
53
- React.useEffect(() => {
54
- if (formStatus === 'pending') {
55
- setPolledData(null)
56
- }
57
- }, [formStatus])
58
-
59
- // Poll for status updates when status is non-terminal
60
- React.useEffect(() => {
61
- const currentStatus = polledData?.status ?? formStatus
62
- if (!currentStatus || currentStatus === 'complete' || currentStatus === 'error') return
63
- if (!collectionSlug || !id) return
64
-
65
- const controller = new AbortController()
66
-
67
- const poll = async () => {
68
- try {
69
- const res = await fetch(`/api/${collectionSlug}/${id}?depth=0`, {
70
- signal: controller.signal,
71
- })
72
- if (!res.ok) return
73
- const doc = await res.json()
74
- const optimizer = doc.imageOptimizer
75
- if (!optimizer) return
76
-
77
- setPolledData({
78
- status: optimizer.status,
79
- originalSize: optimizer.originalSize,
80
- optimizedSize: optimizer.optimizedSize,
81
- thumbHash: optimizer.thumbHash,
82
- error: optimizer.error,
83
- variants: optimizer.variants,
84
- })
85
- } catch {
86
- // Silently ignore fetch errors (abort, network issues)
87
- }
88
- }
89
-
90
- const intervalId = setInterval(poll, POLL_INTERVAL_MS)
91
- // Run immediately on mount
92
- poll()
93
-
94
- return () => {
95
- controller.abort()
96
- clearInterval(intervalId)
97
- }
98
- }, [polledData?.status, formStatus, collectionSlug, id])
99
-
100
- // Use polled data when available, otherwise fall back to form state
101
- const status = polledData?.status ?? formStatus
102
- const originalSize = polledData?.originalSize ?? formOriginalSize
103
- const optimizedSize = polledData?.optimizedSize ?? formOptimizedSize
104
- const thumbHash = polledData?.thumbHash ?? formThumbHash
105
- const error = polledData?.error ?? formError
26
+ const status = formState[`${basePath}.status`]?.value as string | undefined
27
+ const originalSize = formState[`${basePath}.originalSize`]?.value as number | undefined
28
+ const optimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined
29
+ const thumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined
30
+ const error = formState[`${basePath}.error`]?.value as string | undefined
106
31
 
107
32
  const thumbHashUrl = React.useMemo(() => {
108
33
  if (!thumbHash) return null
@@ -114,30 +39,26 @@ export const OptimizationStatus: React.FC<{ path?: string }> = (props) => {
114
39
  }
115
40
  }, [thumbHash])
116
41
 
117
- // Read variants from polled data or form state
42
+ // Read variants array from form state
43
+ const variantsField = formState[`${basePath}.variants`]
44
+ const rowCount = (variantsField as any)?.rows?.length ?? 0
118
45
  const variants: Array<{
119
46
  format?: string
120
47
  filename?: string
121
48
  filesize?: number
122
49
  width?: number
123
50
  height?: number
124
- }> = React.useMemo(() => {
125
- if (polledData?.variants) return polledData.variants
126
-
127
- const variantsField = formState[`${basePath}.variants`]
128
- const rowCount = (variantsField as any)?.rows?.length ?? 0
129
- const formVariants: typeof variants = []
130
- for (let i = 0; i < rowCount; i++) {
131
- formVariants.push({
132
- format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,
133
- filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,
134
- filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,
135
- width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,
136
- height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,
137
- })
138
- }
139
- return formVariants
140
- }, [polledData?.variants, formState, basePath])
51
+ }> = []
52
+
53
+ for (let i = 0; i < rowCount; i++) {
54
+ variants.push({
55
+ format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,
56
+ filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,
57
+ filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,
58
+ width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,
59
+ height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,
60
+ })
61
+ }
141
62
 
142
63
  if (!status) {
143
64
  return (
@@ -5,6 +5,7 @@ import type { CollectionAfterChangeHook } from 'payload'
5
5
  import type { ResolvedImageOptimizerConfig } from '../types.js'
6
6
  import { resolveCollectionConfig } from '../defaults.js'
7
7
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
8
+ import { isCloudStorage } from '../utilities/storage.js'
8
9
 
9
10
  export const createAfterChangeHook = (
10
11
  resolvedConfig: ResolvedImageOptimizerConfig,
@@ -16,28 +17,33 @@ export const createAfterChangeHook = (
16
17
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc
17
18
 
18
19
  const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config
19
- const staticDir = resolveStaticDir(collectionConfig)
20
+ const cloudStorage = isCloudStorage(collectionConfig)
20
21
 
21
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
22
-
23
- // Overwrite the file on disk with the processed (stripped/resized/converted) buffer
24
- // Payload 3.0 writes the original buffer to disk; we replace it here
25
- const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined
26
- if (processedBuffer && doc.filename && staticDir) {
27
- const safeFilename = path.basename(doc.filename as string)
28
- const filePath = path.join(staticDir, safeFilename)
29
- await fs.writeFile(filePath, processedBuffer)
22
+ // When using local storage, overwrite the file on disk with the processed buffer.
23
+ // Payload's uploadFiles step writes the original buffer; we replace it here.
24
+ // When using cloud storage, skip the cloud adapter's afterChange hook already
25
+ // uploads the correct buffer from req.file.data (set in our beforeChange hook).
26
+ if (!cloudStorage) {
27
+ const staticDir = resolveStaticDir(collectionConfig)
28
+ const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined
29
+ if (processedBuffer && doc.filename && staticDir) {
30
+ const safeFilename = path.basename(doc.filename as string)
31
+ const filePath = path.join(staticDir, safeFilename)
32
+ await fs.writeFile(filePath, processedBuffer)
30
33
 
31
- // If replaceOriginal changed the filename, clean up the old file Payload wrote
32
- const originalFilename = context.imageOptimizer_originalFilename as string | undefined
33
- if (originalFilename && originalFilename !== safeFilename) {
34
- const oldFilePath = path.join(staticDir, path.basename(originalFilename))
35
- await fs.unlink(oldFilePath).catch(() => {
36
- // Old file may not exist if Payload used the new filename
37
- })
34
+ // If replaceOriginal changed the filename, clean up the old file Payload wrote
35
+ const originalFilename = context.imageOptimizer_originalFilename as string | undefined
36
+ if (originalFilename && originalFilename !== safeFilename) {
37
+ const oldFilePath = path.join(staticDir, path.basename(originalFilename))
38
+ await fs.unlink(oldFilePath).catch(() => {
39
+ // Old file may not exist if Payload used the new filename
40
+ })
41
+ }
38
42
  }
39
43
  }
40
44
 
45
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
46
+
41
47
  // When replaceOriginal is on and only one format is configured, the main file
42
48
  // is already converted — skip the async job and mark complete immediately.
43
49
  if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
@@ -55,7 +61,25 @@ export const createAfterChangeHook = (
55
61
  return doc
56
62
  }
57
63
 
58
- // Queue async format conversion job for remaining variants
64
+ // With cloud storage, variant files cannot be written — skip the async job
65
+ // and mark complete. CDN-level image optimization (e.g. Next.js Image) can
66
+ // serve alternative formats on the fly.
67
+ if (cloudStorage) {
68
+ await req.payload.update({
69
+ collection: collectionSlug,
70
+ id: doc.id,
71
+ data: {
72
+ imageOptimizer: {
73
+ status: 'complete',
74
+ variants: [],
75
+ },
76
+ },
77
+ context: { imageOptimizer_skip: true },
78
+ })
79
+ return doc
80
+ }
81
+
82
+ // Queue async format conversion job for remaining variants (local storage only)
59
83
  await req.payload.jobs.queue({
60
84
  task: 'imageOptimizer_convertFormats',
61
85
  input: {
@@ -55,8 +55,17 @@ export const createBeforeChangeHook = (
55
55
  data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)
56
56
  }
57
57
 
58
- // Store processed buffer in context for afterChange to write to disk
59
- // (Payload 3.0 does not use modified req.file.data for the disk write)
58
+ // Write processed buffer back to req.file so cloud storage adapters
59
+ // (which read req.file in their afterChange hook) upload the optimized version.
60
+ // Payload's own uploadFiles step does NOT re-read req.file.data for its local
61
+ // disk write, so we also store the buffer in context for our afterChange hook
62
+ // to overwrite the local file when local storage is enabled.
63
+ req.file.data = finalBuffer
64
+ req.file.size = finalSize
65
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
66
+ req.file.name = data.filename
67
+ req.file.mimetype = data.mimeType
68
+ }
60
69
  context.imageOptimizer_processedBuffer = finalBuffer
61
70
 
62
71
  return data
@@ -7,6 +7,7 @@ import type { ResolvedImageOptimizerConfig } from '../types.js'
7
7
  import { resolveCollectionConfig } from '../defaults.js'
8
8
  import { convertFormat } from '../processing/index.js'
9
9
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
10
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'
10
11
 
11
12
  export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
12
13
  return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
@@ -17,16 +18,31 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
17
18
  })
18
19
 
19
20
  const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
20
- const staticDir = resolveStaticDir(collectionConfig)
21
+ const cloudStorage = isCloudStorage(collectionConfig)
22
+
23
+ // Cloud storage: variant files cannot be uploaded without direct adapter access.
24
+ // Mark as complete — CDN-level image optimization handles format conversion.
25
+ if (cloudStorage) {
26
+ await req.payload.update({
27
+ collection: input.collectionSlug as CollectionSlug,
28
+ id: input.docId,
29
+ data: {
30
+ imageOptimizer: {
31
+ status: 'complete',
32
+ variants: [],
33
+ },
34
+ },
35
+ context: { imageOptimizer_skip: true },
36
+ })
37
+ return { output: { variantsGenerated: 0 } }
38
+ }
21
39
 
40
+ const staticDir = resolveStaticDir(collectionConfig)
22
41
  if (!staticDir) {
23
42
  throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`)
24
43
  }
25
44
 
26
- // Sanitize filename to prevent path traversal
27
- const safeFilename = path.basename(doc.filename)
28
- const filePath = path.join(staticDir, safeFilename)
29
- const fileBuffer = await fs.readFile(filePath)
45
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig)
30
46
 
31
47
  const variants: Array<{
32
48
  filename: string
@@ -46,6 +62,8 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
46
62
  ? perCollectionConfig.formats.slice(1)
47
63
  : perCollectionConfig.formats
48
64
 
65
+ const safeFilename = path.basename(doc.filename)
66
+
49
67
  for (const format of formatsToGenerate) {
50
68
  const result = await convertFormat(fileBuffer, format.format, format.quality)
51
69
  const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`
@@ -7,6 +7,7 @@ import type { ResolvedImageOptimizerConfig } from '../types.js'
7
7
  import { resolveCollectionConfig } from '../defaults.js'
8
8
  import { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'
9
9
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
10
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'
10
11
 
11
12
  export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
12
13
  return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
@@ -22,34 +23,14 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
22
23
  }
23
24
 
24
25
  const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
25
- const staticDir = resolveStaticDir(collectionConfig)
26
+ const cloudStorage = isCloudStorage(collectionConfig)
26
27
 
27
- if (!staticDir) {
28
- throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`)
29
- }
28
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig)
29
+ const originalSize = fileBuffer.length
30
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
30
31
 
31
32
  // Sanitize filename to prevent path traversal
32
33
  const safeFilename = path.basename(doc.filename)
33
- const filePath = path.join(staticDir, safeFilename)
34
-
35
- let fileBuffer: Buffer
36
- try {
37
- fileBuffer = await fs.readFile(filePath)
38
- } catch {
39
- // If file not on disk, try fetching from URL
40
- if (doc.url) {
41
- const url = doc.url.startsWith('http')
42
- ? doc.url
43
- : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
44
- const response = await fetch(url)
45
- fileBuffer = Buffer.from(await response.arrayBuffer())
46
- } else {
47
- throw new Error(`File not found: ${filePath}`)
48
- }
49
- }
50
-
51
- const originalSize = fileBuffer.length
52
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
53
34
 
54
35
  // Step 1: Strip metadata + resize
55
36
  const processed = await stripAndResize(
@@ -73,26 +54,13 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
73
54
  newMimeType = converted.mimeType
74
55
  }
75
56
 
76
- // Write optimized file to disk
77
- const newFilePath = path.join(staticDir, newFilename)
78
- await fs.writeFile(newFilePath, mainBuffer)
79
-
80
- // Clean up old file if filename changed
81
- if (newFilename !== safeFilename) {
82
- await fs.unlink(filePath).catch(() => {})
83
- }
84
-
85
57
  // Step 2: Generate ThumbHash
86
58
  let thumbHash: string | undefined
87
59
  if (resolvedConfig.generateThumbHash) {
88
60
  thumbHash = await generateThumbHash(mainBuffer)
89
61
  }
90
62
 
91
- // Step 3: Convert to configured formats (skip primary when replaceOriginal)
92
- const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
93
- ? perCollectionConfig.formats.slice(1)
94
- : perCollectionConfig.formats
95
-
63
+ // Step 3: Store the optimized file
96
64
  const variants: Array<{
97
65
  filename: string
98
66
  filesize: number
@@ -103,47 +71,96 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
103
71
  width: number
104
72
  }> = []
105
73
 
106
- for (const format of formatsToGenerate) {
107
- const result = await convertFormat(mainBuffer, format.format, format.quality)
108
- const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`
109
- await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)
110
-
111
- variants.push({
112
- format: format.format,
113
- filename: variantFilename,
114
- filesize: result.size,
115
- width: result.width,
116
- height: result.height,
117
- mimeType: result.mimeType,
118
- url: `/api/${input.collectionSlug}/file/${variantFilename}`,
74
+ if (cloudStorage) {
75
+ // Cloud storage: re-upload the optimized file via Payload's update API.
76
+ // This triggers the cloud adapter's afterChange hook which uploads to cloud.
77
+ const updateData: Record<string, any> = {
78
+ imageOptimizer: {
79
+ originalSize,
80
+ optimizedSize: mainSize,
81
+ status: 'complete',
82
+ thumbHash,
83
+ variants: [],
84
+ error: null,
85
+ },
86
+ }
87
+
88
+ if (newFilename !== safeFilename) {
89
+ updateData.filename = newFilename
90
+ updateData.filesize = mainSize
91
+ updateData.mimeType = newMimeType
92
+ }
93
+
94
+ await req.payload.update({
95
+ collection: input.collectionSlug as CollectionSlug,
96
+ id: input.docId,
97
+ data: updateData,
98
+ file: {
99
+ data: mainBuffer,
100
+ mimetype: newMimeType || doc.mimeType,
101
+ name: newFilename,
102
+ size: mainSize,
103
+ },
104
+ context: { imageOptimizer_skip: true },
119
105
  })
120
- }
106
+ } else {
107
+ // Local storage: write files to disk
108
+ const staticDir = resolveStaticDir(collectionConfig)
109
+ const newFilePath = path.join(staticDir, newFilename)
110
+ await fs.writeFile(newFilePath, mainBuffer)
111
+
112
+ // Clean up old file if filename changed
113
+ if (newFilename !== safeFilename) {
114
+ const oldFilePath = path.join(staticDir, safeFilename)
115
+ await fs.unlink(oldFilePath).catch(() => {})
116
+ }
121
117
 
122
- // Step 4: Update the document with all optimization data
123
- const updateData: Record<string, any> = {
124
- imageOptimizer: {
125
- originalSize,
126
- optimizedSize: mainSize,
127
- status: 'complete',
128
- thumbHash,
129
- variants,
130
- error: null,
131
- },
132
- }
118
+ // Generate variant files (local storage only)
119
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
120
+ ? perCollectionConfig.formats.slice(1)
121
+ : perCollectionConfig.formats
122
+
123
+ for (const format of formatsToGenerate) {
124
+ const result = await convertFormat(mainBuffer, format.format, format.quality)
125
+ const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`
126
+ await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)
127
+
128
+ variants.push({
129
+ format: format.format,
130
+ filename: variantFilename,
131
+ filesize: result.size,
132
+ width: result.width,
133
+ height: result.height,
134
+ mimeType: result.mimeType,
135
+ url: `/api/${input.collectionSlug}/file/${variantFilename}`,
136
+ })
137
+ }
133
138
 
134
- // Update filename, mimeType, and filesize when replaceOriginal changed them
135
- if (newFilename !== safeFilename) {
136
- updateData.filename = newFilename
137
- updateData.filesize = mainSize
138
- updateData.mimeType = newMimeType
139
- }
139
+ // Update the document with optimization data
140
+ const updateData: Record<string, any> = {
141
+ imageOptimizer: {
142
+ originalSize,
143
+ optimizedSize: mainSize,
144
+ status: 'complete',
145
+ thumbHash,
146
+ variants,
147
+ error: null,
148
+ },
149
+ }
140
150
 
141
- await req.payload.update({
142
- collection: input.collectionSlug as CollectionSlug,
143
- id: input.docId,
144
- data: updateData,
145
- context: { imageOptimizer_skip: true },
146
- })
151
+ if (newFilename !== safeFilename) {
152
+ updateData.filename = newFilename
153
+ updateData.filesize = mainSize
154
+ updateData.mimeType = newMimeType
155
+ }
156
+
157
+ await req.payload.update({
158
+ collection: input.collectionSlug as CollectionSlug,
159
+ id: input.docId,
160
+ data: updateData,
161
+ context: { imageOptimizer_skip: true },
162
+ })
163
+ }
147
164
 
148
165
  return { output: { status: 'complete' } }
149
166
  } catch (err) {
@@ -0,0 +1,50 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+
4
+ import { resolveStaticDir } from './resolveStaticDir.js'
5
+
6
+ /**
7
+ * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
8
+ * When true, files are uploaded by external adapter hooks — no local FS writes should happen.
9
+ */
10
+ export function isCloudStorage(collectionConfig: { upload?: boolean | Record<string, any> }): boolean {
11
+ return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true
12
+ }
13
+
14
+ /**
15
+ * Reads a file buffer from local disk or fetches it from URL.
16
+ * Tries local disk first (when available), falls back to URL fetch.
17
+ * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
18
+ */
19
+ export async function fetchFileBuffer(
20
+ doc: { filename?: string; url?: string },
21
+ collectionConfig: { upload?: boolean | Record<string, any> },
22
+ ): Promise<Buffer> {
23
+ const safeFilename = doc.filename ? path.basename(doc.filename) : undefined
24
+
25
+ // Try local disk first (only when local storage is enabled)
26
+ if (!isCloudStorage(collectionConfig) && safeFilename) {
27
+ const staticDir = resolveStaticDir(collectionConfig)
28
+ if (staticDir) {
29
+ try {
30
+ return await fs.readFile(path.join(staticDir, safeFilename))
31
+ } catch {
32
+ // Fall through to URL fetch
33
+ }
34
+ }
35
+ }
36
+
37
+ // Fetch from URL (works for cloud storage and as fallback for local)
38
+ if (doc.url) {
39
+ const url = doc.url.startsWith('http')
40
+ ? doc.url
41
+ : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
42
+ const response = await fetch(url)
43
+ if (!response.ok) {
44
+ throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)
45
+ }
46
+ return Buffer.from(await response.arrayBuffer())
47
+ }
48
+
49
+ throw new Error(`Cannot read file: no local path or URL available for "${doc.filename}"`)
50
+ }