webspresso 0.0.105 → 0.0.106

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 (134) hide show
  1. package/.agents/AGENTS.md +15 -2
  2. package/.agents/CLI.md +219 -7
  3. package/.agents/FLAGS.md +118 -0
  4. package/.agents/LOGGING.md +93 -0
  5. package/.agents/METRICS.md +127 -0
  6. package/.agents/MIGRATION_JOBS.md +95 -0
  7. package/.agents/PLUGINS.md +10 -3
  8. package/.agents/QUEUE.md +42 -0
  9. package/.agents/RATE_LIMIT.md +147 -0
  10. package/.agents/SCALE_OUT.md +109 -0
  11. package/.agents/SECRETS.md +117 -0
  12. package/AGENTS.md +10 -0
  13. package/bin/commands/add-aws.js +396 -0
  14. package/bin/commands/add-ci.js +523 -0
  15. package/bin/commands/add-cloudflare.js +240 -0
  16. package/bin/commands/add-cloudrun.js +381 -0
  17. package/bin/commands/add-migration-job.js +396 -0
  18. package/bin/commands/assets-sync.js +209 -0
  19. package/bin/commands/client-generate.js +52 -0
  20. package/bin/commands/console.js +53 -0
  21. package/bin/commands/db-migrate.js +47 -2
  22. package/bin/commands/db-studio.js +84 -0
  23. package/bin/commands/deploy-check.js +242 -13
  24. package/bin/commands/export.js +87 -0
  25. package/bin/commands/secrets-pull.js +208 -0
  26. package/bin/utils/db.js +7 -1
  27. package/bin/webspresso.js +22 -0
  28. package/core/app.js +421 -0
  29. package/core/auth/authenticate.js +93 -0
  30. package/core/auth/index.js +34 -4
  31. package/core/auth/manager.js +50 -5
  32. package/core/auth/middleware.js +16 -68
  33. package/core/auth/redis-session.js +1008 -0
  34. package/core/auth/sessions.js +156 -0
  35. package/core/cache/cdn.js +296 -0
  36. package/core/cache/index.js +12 -0
  37. package/core/cache/tagged-lru.js +9 -1
  38. package/core/console/commands.js +148 -0
  39. package/core/console/context.js +260 -0
  40. package/core/console/eval.js +70 -0
  41. package/core/console/index.js +22 -0
  42. package/core/console/repl.js +102 -0
  43. package/core/errors/domain.js +1 -0
  44. package/core/events/dispatcher.js +7 -0
  45. package/core/events/signal.js +7 -0
  46. package/core/export/discovery.js +191 -0
  47. package/core/export/exporter.js +277 -0
  48. package/core/export/index.js +24 -0
  49. package/core/flags/evaluator.js +252 -0
  50. package/core/flags/hasher.js +37 -0
  51. package/core/flags/index.js +21 -0
  52. package/core/flags/manager.js +263 -0
  53. package/core/flags/store.js +126 -0
  54. package/core/health/middleware.js +3 -3
  55. package/core/http/adapter.js +74 -0
  56. package/core/http/express-adapter.js +46 -0
  57. package/core/http/hono-adapter.js +531 -0
  58. package/core/http/index.js +41 -0
  59. package/core/http/pipeline.js +485 -0
  60. package/core/index.js +336 -0
  61. package/core/limits/middleware.js +9 -1
  62. package/core/logger/formatters.js +264 -0
  63. package/core/logger/index.js +260 -0
  64. package/core/logger/trace.js +137 -0
  65. package/core/metrics/collectors.js +125 -0
  66. package/core/metrics/index.js +45 -0
  67. package/core/metrics/middleware.js +230 -0
  68. package/core/metrics/registry.js +366 -0
  69. package/core/orm/d1.js +265 -0
  70. package/core/orm/index.js +105 -5
  71. package/core/orm/migrate-cloud.js +194 -0
  72. package/core/orm/pool.js +97 -0
  73. package/core/orm/query-builder.js +9 -0
  74. package/core/queue/adapters/cloudflare.js +131 -0
  75. package/core/queue/adapters/pubsub.js +400 -0
  76. package/core/queue/adapters/sqs.js +419 -0
  77. package/core/queue/adapters/wait-until.js +77 -0
  78. package/core/queue/index.js +8 -0
  79. package/core/queue/manager.js +11 -0
  80. package/core/rate-limit/index.js +22 -0
  81. package/core/rate-limit/limiter.js +219 -0
  82. package/core/rate-limit/stores/memory.js +129 -0
  83. package/core/rate-limit/stores/redis.js +151 -0
  84. package/core/rate-limit/utils.js +112 -0
  85. package/core/resilience/circuit-breaker.js +31 -0
  86. package/core/secrets/index.js +113 -0
  87. package/core/secrets/manager.js +308 -0
  88. package/core/secrets/providers/aws.js +288 -0
  89. package/core/secrets/providers/env.js +44 -0
  90. package/core/secrets/providers/gcp.js +307 -0
  91. package/core/secrets/providers/memory.js +76 -0
  92. package/core/shutdown/shutdown-manager.js +9 -0
  93. package/core/stream/index.js +19 -0
  94. package/core/stream/text-stream.js +326 -0
  95. package/core/studio/index.js +43 -0
  96. package/core/studio/inspector.js +258 -0
  97. package/core/studio/server.js +325 -0
  98. package/core/studio/ui.js +681 -0
  99. package/index.d.ts +934 -2
  100. package/index.js +125 -3
  101. package/package.json +3 -1
  102. package/plugins/admin-panel/index.js +12 -5
  103. package/plugins/api-key/index.js +3 -2
  104. package/plugins/audit-log/index.js +3 -1
  105. package/plugins/basic-auth/index.js +3 -2
  106. package/plugins/cors.js +3 -2
  107. package/plugins/csrf/index.js +4 -3
  108. package/plugins/csv/index.js +8 -5
  109. package/plugins/email/index.js +11 -9
  110. package/plugins/index.js +3 -0
  111. package/plugins/queue/index.js +18 -2
  112. package/plugins/redirect/index.js +7 -2
  113. package/plugins/seo-checker/index.js +2 -2
  114. package/plugins/site-analytics/index.js +3 -1
  115. package/plugins/swagger.js +8 -2
  116. package/plugins/upload/gcs-provider.js +148 -0
  117. package/plugins/upload/index.js +132 -74
  118. package/plugins/upload/s3-provider.js +257 -0
  119. package/plugins/webhooks/index.js +27 -23
  120. package/plugins/xlsx/index.js +7 -5
  121. package/src/api/api-loader.js +12 -95
  122. package/src/api/define-api.js +1 -0
  123. package/src/client-runtime/constants.js +10 -0
  124. package/src/client-runtime/mount.js +2 -3
  125. package/src/dev/client-gen.js +458 -0
  126. package/src/discovery/scan-routes.js +5 -2
  127. package/src/file-router.js +2 -0
  128. package/src/helpers.js +53 -0
  129. package/src/pages/page-loader.js +12 -205
  130. package/src/plugin-manager.js +9 -1
  131. package/src/server.js +231 -140
  132. package/src/services/executor.js +9 -0
  133. package/src/services/registry.js +9 -1
  134. package/src/services/validator.js +38 -20
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Google Cloud Storage (GCS) File Provider for Webspresso
3
+ * Enables direct file storage, streaming, and signed upload URLs with Google Cloud Storage
4
+ * Zero-dependency compatible: supports official @google-cloud/storage or standard duck-typed client interfaces
5
+ * @module plugins/upload/gcs-provider
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { generateStorageKey } = require('./s3-provider');
11
+
12
+ /**
13
+ * Google Cloud Storage Provider
14
+ * @param {Object} options
15
+ * @param {Object|string} options.bucket - GCS Bucket name (string) or Bucket instance (e.g. storage.bucket('my-bucket'))
16
+ * @param {Object} [options.storageClient] - Optional Storage instance from @google-cloud/storage
17
+ * @param {string} [options.publicBaseUrl] - Custom public domain or CDN URL (e.g. 'https://cdn.example.com')
18
+ * @param {string} [options.prefix='uploads'] - Subdirectory prefix inside the GCS bucket
19
+ * @returns {Object} Upload storage provider implementing put, delete, and createSignedUploadUrl
20
+ */
21
+ function createGcsFileProvider(options = {}) {
22
+ const { storageClient, publicBaseUrl, prefix = 'uploads' } = options;
23
+ let bucket = options.bucket;
24
+
25
+ if (!bucket) {
26
+ throw new Error('[createGcsFileProvider] GCS Bucket (name string or Bucket object) is required.');
27
+ }
28
+
29
+ const bucketName = typeof bucket === 'string' ? bucket : (bucket.name || 'bucket');
30
+ let bucketInstance = typeof bucket === 'object' && bucket !== null ? bucket : null;
31
+
32
+ if (typeof bucket === 'string' && storageClient && typeof storageClient.bucket === 'function') {
33
+ bucketInstance = storageClient.bucket(bucket);
34
+ }
35
+
36
+ const defaultBaseUrl = `https://storage.googleapis.com/${bucketName}`;
37
+ const normalizedBaseUrl = (publicBaseUrl || defaultBaseUrl).replace(/\/+$/, '');
38
+
39
+ return {
40
+ bucketName,
41
+ publicBaseUrl: normalizedBaseUrl,
42
+
43
+ /**
44
+ * Store file in Google Cloud Storage
45
+ * @param {Object} args
46
+ * @param {Buffer} args.buffer
47
+ * @param {string} args.originalName
48
+ * @param {string} args.mimeType
49
+ * @returns {Promise<{ publicUrl: string, key: string }>}
50
+ */
51
+ async put({ buffer, originalName, mimeType }) {
52
+ const key = generateStorageKey(originalName, mimeType, prefix);
53
+
54
+ if (bucketInstance && typeof bucketInstance.file === 'function') {
55
+ const file = bucketInstance.file(key);
56
+ await file.save(buffer, {
57
+ metadata: {
58
+ contentType: mimeType || 'application/octet-stream',
59
+ metadata: {
60
+ originalName: originalName || '',
61
+ uploadedAt: new Date().toISOString(),
62
+ },
63
+ },
64
+ resumable: false,
65
+ });
66
+ } else {
67
+ throw new Error(
68
+ '[createGcsFileProvider] A valid GCS bucket instance with .file() is required to perform .put().'
69
+ );
70
+ }
71
+
72
+ const publicUrl = `${normalizedBaseUrl}/${key}`;
73
+ return { publicUrl, key };
74
+ },
75
+
76
+ /**
77
+ * Delete file from Google Cloud Storage
78
+ * @param {string} key
79
+ * @returns {Promise<boolean>}
80
+ */
81
+ async delete(key) {
82
+ if (bucketInstance && typeof bucketInstance.file === 'function') {
83
+ const file = bucketInstance.file(key);
84
+ try {
85
+ if (typeof file.delete === 'function') {
86
+ await file.delete({ ignoreNotFound: true });
87
+ return true;
88
+ }
89
+ } catch (_) {
90
+ return false;
91
+ }
92
+ }
93
+ return false;
94
+ },
95
+
96
+ /**
97
+ * Generate a V4 signed URL for direct browser-to-GCS upload
98
+ * @param {Object} [params]
99
+ * @param {string} [params.originalName]
100
+ * @param {string} [params.mimeType]
101
+ * @param {number} [params.expiresInMs=900000] - 15 minutes default
102
+ * @param {string} [params.key] - Custom key override
103
+ * @returns {Promise<{ uploadUrl: string, publicUrl: string, key: string, method: string, headers: Record<string, string> }>}
104
+ */
105
+ async createSignedUploadUrl(params = {}) {
106
+ const {
107
+ originalName = 'upload',
108
+ mimeType = 'application/octet-stream',
109
+ expiresInMs = 15 * 60 * 1000,
110
+ key: customKey,
111
+ } = params;
112
+
113
+ const key = customKey || generateStorageKey(originalName, mimeType, prefix);
114
+
115
+ if (!bucketInstance || typeof bucketInstance.file !== 'function') {
116
+ throw new Error('[createGcsFileProvider] GCS bucket instance required for generating signed upload URLs.');
117
+ }
118
+
119
+ const file = bucketInstance.file(key);
120
+ if (typeof file.getSignedUrl !== 'function') {
121
+ throw new Error('[createGcsFileProvider] file.getSignedUrl() method is not implemented on GCS File instance.');
122
+ }
123
+
124
+ const [signedUrl] = await file.getSignedUrl({
125
+ version: 'v4',
126
+ action: 'write',
127
+ expires: Date.now() + expiresInMs,
128
+ contentType: mimeType,
129
+ });
130
+
131
+ const publicUrl = `${normalizedBaseUrl}/${key}`;
132
+
133
+ return {
134
+ uploadUrl: signedUrl,
135
+ publicUrl,
136
+ key,
137
+ method: 'PUT',
138
+ headers: {
139
+ 'Content-Type': mimeType,
140
+ },
141
+ };
142
+ },
143
+ };
144
+ }
145
+
146
+ module.exports = {
147
+ createGcsFileProvider,
148
+ };
@@ -122,6 +122,122 @@ function uploadPlugin(options = {}) {
122
122
  const mw = ensureArray(middleware).filter(Boolean);
123
123
  const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
124
124
 
125
+ function mountRoutes(ctx) {
126
+ if (!ctx || typeof ctx.addRoute !== 'function') return;
127
+
128
+ if (ctx.app && typeof ctx.app.set === 'function') {
129
+ ctx.app.set('webspresso.uploadPath', normalizedPath);
130
+ }
131
+
132
+ const handler = (req, res) => {
133
+ parseMultipart(req, res, async (err) => {
134
+ if (err) {
135
+ const { status, message } = multerErrorResponse(err);
136
+ return res.status(status).json({ error: message, message });
137
+ }
138
+
139
+ const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
140
+ if (files.length === 0) {
141
+ return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
142
+ }
143
+
144
+ // Validate all files first
145
+ for (const file of files) {
146
+ if (!file || !file.buffer) {
147
+ return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
148
+ }
149
+
150
+ const mime = file.mimetype || 'application/octet-stream';
151
+ const originalName = file.originalname || 'upload';
152
+ const ext = extensionFromName(originalName);
153
+
154
+ if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
155
+ return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
156
+ }
157
+ if (
158
+ extensionAllowlist &&
159
+ extensionAllowlist.length &&
160
+ (!ext || !extensionAllowlist.includes(ext))
161
+ ) {
162
+ return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
163
+ }
164
+ }
165
+
166
+ try {
167
+ const uploadPromises = files.map(async (file) => {
168
+ const mime = file.mimetype || 'application/octet-stream';
169
+ const originalName = file.originalname || 'upload';
170
+ const result = await provider.put({
171
+ buffer: file.buffer,
172
+ originalName,
173
+ mimeType: mime,
174
+ size: file.size,
175
+ req,
176
+ });
177
+ return {
178
+ url: result.publicUrl,
179
+ publicUrl: result.publicUrl,
180
+ key: result.key,
181
+ };
182
+ });
183
+
184
+ const results = await Promise.all(uploadPromises);
185
+
186
+ if (multiple) {
187
+ res.json(results);
188
+ } else {
189
+ res.json(results[0]);
190
+ }
191
+ } catch (e) {
192
+ const message = e instanceof Error ? e.message : String(e);
193
+ res.status(500).json({ error: message, message });
194
+ }
195
+ });
196
+ };
197
+
198
+ ctx.addRoute('post', normalizedPath, ...mw, handler);
199
+
200
+ // Direct presigned upload endpoint for cloud storage (AWS S3, GCS, R2)
201
+ if (options.presigned) {
202
+ const presignPath = options.presignedRoute ? ensureLeadingSlash(options.presignedRoute) : `${normalizedPath}/presign`;
203
+ const presignMw = ensureArray(options.presignedMiddleware || options.middleware || []);
204
+
205
+ ctx.addRoute('post', presignPath, ...presignMw, async (req, res) => {
206
+ if (!provider || typeof provider.createSignedUploadUrl !== 'function') {
207
+ return res.status(400).json({
208
+ error: 'Configured upload provider does not support direct presigned uploads',
209
+ });
210
+ }
211
+
212
+ const { originalName, mimeType, key, expiresInSeconds, expiresInMs } = req.body || {};
213
+ if (!originalName) {
214
+ return res.status(400).json({ error: 'originalName is required in request body' });
215
+ }
216
+
217
+ try {
218
+ const result = await provider.createSignedUploadUrl({
219
+ originalName,
220
+ mimeType: mimeType || 'application/octet-stream',
221
+ key,
222
+ expiresInSeconds,
223
+ expiresInMs,
224
+ });
225
+ return res.json(result);
226
+ } catch (err) {
227
+ return res.status(500).json({ error: err.message || 'Failed to create presigned upload URL' });
228
+ }
229
+ });
230
+
231
+ if (process.env.NODE_ENV !== 'production') {
232
+ console.log(` Upload plugin: POST ${presignPath} (Presigned URL)`);
233
+ }
234
+ }
235
+
236
+ if (process.env.NODE_ENV !== 'production') {
237
+ console.log(` Upload plugin: POST ${normalizedPath}`);
238
+ }
239
+ }
240
+
125
241
  return {
126
242
  name: 'upload',
127
243
  version: '1.0.0',
@@ -160,83 +276,13 @@ function uploadPlugin(options = {}) {
160
276
  }
161
277
  }
162
278
  }
279
+ if (typeof ctx.addRoute === 'function' && !ctx.core) {
280
+ mountRoutes(ctx);
281
+ }
163
282
  },
164
283
 
165
284
  onRoutesReady(ctx) {
166
- const { app } = ctx;
167
- app.set('webspresso.uploadPath', normalizedPath);
168
-
169
- const handler = (req, res) => {
170
- parseMultipart(req, res, async (err) => {
171
- if (err) {
172
- const { status, message } = multerErrorResponse(err);
173
- return res.status(status).json({ error: message, message });
174
- }
175
-
176
- const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
177
- if (files.length === 0) {
178
- return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
179
- }
180
-
181
- // Validate all files first
182
- for (const file of files) {
183
- if (!file || !file.buffer) {
184
- return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
185
- }
186
-
187
- const mime = file.mimetype || 'application/octet-stream';
188
- const originalName = file.originalname || 'upload';
189
- const ext = extensionFromName(originalName);
190
-
191
- if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
192
- return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
193
- }
194
- if (
195
- extensionAllowlist &&
196
- extensionAllowlist.length &&
197
- (!ext || !extensionAllowlist.includes(ext))
198
- ) {
199
- return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
200
- }
201
- }
202
-
203
- try {
204
- const uploadPromises = files.map(async (file) => {
205
- const mime = file.mimetype || 'application/octet-stream';
206
- const originalName = file.originalname || 'upload';
207
- const result = await provider.put({
208
- buffer: file.buffer,
209
- originalName,
210
- mimeType: mime,
211
- size: file.size,
212
- req,
213
- });
214
- return {
215
- url: result.publicUrl,
216
- publicUrl: result.publicUrl,
217
- key: result.key,
218
- };
219
- });
220
-
221
- const results = await Promise.all(uploadPromises);
222
-
223
- if (multiple) {
224
- res.json(results);
225
- } else {
226
- res.json(results[0]);
227
- }
228
- } catch (e) {
229
- const message = e instanceof Error ? e.message : String(e);
230
- res.status(500).json({ error: message, message });
231
- }
232
- });
233
- };
234
-
235
- ctx.addRoute('post', normalizedPath, ...mw, handler);
236
-
237
- if (process.env.NODE_ENV !== 'production') {
238
- console.log(` Upload plugin: POST ${normalizedPath}`);
239
- }
285
+ mountRoutes(ctx);
240
286
  },
241
287
  };
242
288
  }
@@ -245,10 +291,22 @@ const {
245
291
  scanOrphanedUploads,
246
292
  pruneOrphanedUploads,
247
293
  } = require('./orphan-pruner');
294
+ const {
295
+ createR2FileProvider,
296
+ createS3FileProvider,
297
+ generateStorageKey,
298
+ } = require('./s3-provider');
299
+ const {
300
+ createGcsFileProvider,
301
+ } = require('./gcs-provider');
248
302
 
249
303
  module.exports = {
250
304
  uploadPlugin,
251
305
  createLocalFileProvider,
306
+ createR2FileProvider,
307
+ createS3FileProvider,
308
+ createGcsFileProvider,
309
+ generateStorageKey,
252
310
  scanOrphanedUploads,
253
311
  pruneOrphanedUploads,
254
312
  };
@@ -0,0 +1,257 @@
1
+ /**
2
+ * S3 & Cloudflare R2 Upload Storage Providers for Webspresso
3
+ * Enables direct file storage in Cloudflare R2 (Worker bindings or S3 API) and AWS S3/MinIO
4
+ * @module plugins/upload/s3-provider
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const { randomBytes } = require('crypto');
11
+ const { resolveStoredExtension } = require('./local-file-provider');
12
+
13
+ /**
14
+ * Generate a secure, unique object storage key
15
+ * @param {string} originalName
16
+ * @param {string} mimeType
17
+ * @param {string} [prefix='']
18
+ * @returns {string}
19
+ */
20
+ function generateStorageKey(originalName, mimeType, prefix = '') {
21
+ const base = path.basename(originalName || 'upload');
22
+ let ext = path.extname(base).toLowerCase();
23
+ if (!/^\.[a-z0-9]{1,12}$/.test(ext)) {
24
+ ext = '';
25
+ }
26
+ ext = resolveStoredExtension(mimeType, ext);
27
+
28
+ const cleanPrefix = prefix ? prefix.replace(/^\/+|\/+$/g, '') + '/' : '';
29
+ const uniqueName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`;
30
+ return `${cleanPrefix}${uniqueName}`;
31
+ }
32
+
33
+ /**
34
+ * Cloudflare R2 Native Worker Storage Provider
35
+ * Zero-dependency: interacts directly with Cloudflare Workers R2Bucket binding (env.BUCKET)
36
+ * @param {Object} options
37
+ * @param {Object} options.bucket - Cloudflare R2 Bucket binding (e.g. env.BUCKET)
38
+ * @param {string} options.publicBaseUrl - Public CDN or R2 custom domain URL (e.g. 'https://cdn.example.com')
39
+ * @param {string} [options.prefix='uploads'] - Directory prefix inside bucket
40
+ * @returns {import('./index').UploadStorageProvider}
41
+ */
42
+ function createR2FileProvider(options = {}) {
43
+ const { bucket, publicBaseUrl, prefix = 'uploads' } = options;
44
+
45
+ if (!bucket) {
46
+ throw new Error('[createR2FileProvider] R2 Bucket binding (env.BUCKET) is required.');
47
+ }
48
+
49
+ const normalizedBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '');
50
+
51
+ return {
52
+ /**
53
+ * Store file in Cloudflare R2
54
+ */
55
+ async put({ buffer, originalName, mimeType, size, req }) {
56
+ void size;
57
+ void req;
58
+
59
+ const key = generateStorageKey(originalName, mimeType, prefix);
60
+
61
+ if (typeof bucket.put !== 'function') {
62
+ throw new Error('[createR2FileProvider] Provided bucket binding does not implement .put() method.');
63
+ }
64
+
65
+ await bucket.put(key, buffer, {
66
+ httpMetadata: {
67
+ contentType: mimeType || 'application/octet-stream',
68
+ },
69
+ customMetadata: {
70
+ originalName: originalName || '',
71
+ uploadedAt: new Date().toISOString(),
72
+ },
73
+ });
74
+
75
+ const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `/${key}`;
76
+ return { publicUrl, key };
77
+ },
78
+
79
+ /**
80
+ * Delete file from Cloudflare R2
81
+ * @param {string} key
82
+ */
83
+ async delete(key) {
84
+ if (typeof bucket.delete === 'function') {
85
+ await bucket.delete(key);
86
+ return true;
87
+ }
88
+ return false;
89
+ },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Generic S3 Storage Provider (AWS S3, MinIO, or S3-compatible R2)
95
+ * @param {Object} options
96
+ * @param {Object} options.s3Client - S3 Client instance (e.g. @aws-sdk/client-s3 S3Client)
97
+ * @param {string} options.bucket - Bucket name
98
+ * @param {string} [options.publicBaseUrl] - Public CDN / bucket base URL
99
+ * @param {string} [options.prefix='uploads'] - Key prefix
100
+ * @returns {import('./index').UploadStorageProvider}
101
+ */
102
+ function createS3FileProvider(options = {}) {
103
+ const { s3Client, bucket, publicBaseUrl, prefix = 'uploads' } = options;
104
+
105
+ if (!s3Client || typeof s3Client.send !== 'function') {
106
+ throw new Error('[createS3FileProvider] An S3 client instance with a .send() method is required.');
107
+ }
108
+ if (!bucket) {
109
+ throw new Error('[createS3FileProvider] Bucket name is required.');
110
+ }
111
+
112
+ const normalizedBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '');
113
+
114
+ return {
115
+ async put({ buffer, originalName, mimeType, size, req }) {
116
+ void size;
117
+ void req;
118
+
119
+ const key = generateStorageKey(originalName, mimeType, prefix);
120
+
121
+ // Dynamically load PutObjectCommand or construct Command payload
122
+ let command;
123
+ try {
124
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
125
+ command = new PutObjectCommand({
126
+ Bucket: bucket,
127
+ Key: key,
128
+ Body: buffer,
129
+ ContentType: mimeType || 'application/octet-stream',
130
+ });
131
+ } catch (_) {
132
+ // Fallback for custom or duck-typed S3 client wrappers
133
+ command = {
134
+ constructor: { name: 'PutObjectCommand' },
135
+ input: {
136
+ Bucket: bucket,
137
+ Key: key,
138
+ Body: buffer,
139
+ ContentType: mimeType || 'application/octet-stream',
140
+ },
141
+ };
142
+ }
143
+
144
+ await s3Client.send(command);
145
+
146
+ const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `/${key}`;
147
+ return { publicUrl, key };
148
+ },
149
+
150
+ async delete(key) {
151
+ try {
152
+ let command;
153
+ try {
154
+ const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
155
+ command = new DeleteObjectCommand({
156
+ Bucket: bucket,
157
+ Key: key,
158
+ });
159
+ } catch (_) {
160
+ command = {
161
+ constructor: { name: 'DeleteObjectCommand' },
162
+ input: {
163
+ Bucket: bucket,
164
+ Key: key,
165
+ },
166
+ };
167
+ }
168
+ await s3Client.send(command);
169
+ return true;
170
+ } catch (_) {
171
+ return false;
172
+ }
173
+ },
174
+
175
+ /**
176
+ * Generate a signed PUT URL for direct browser-to-S3 client upload
177
+ * @param {Object} [params]
178
+ * @param {string} [params.originalName]
179
+ * @param {string} [params.mimeType]
180
+ * @param {number} [params.expiresInSeconds=900] - 15 minutes default
181
+ * @param {string} [params.key] - Custom key override
182
+ * @returns {Promise<{ uploadUrl: string, publicUrl: string, key: string, method: string, headers: Record<string, string> }>}
183
+ */
184
+ async createSignedUploadUrl(params = {}) {
185
+ const {
186
+ originalName = 'upload',
187
+ mimeType = 'application/octet-stream',
188
+ expiresInSeconds = 900,
189
+ key: customKey,
190
+ } = params;
191
+
192
+ const key = customKey || generateStorageKey(originalName, mimeType, prefix);
193
+ const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `https://${bucket}.s3.amazonaws.com/${key}`;
194
+
195
+ // 1. Duck-typed client with direct getSignedUrl method (e.g. AWS SDK v2, MinIO, or mock)
196
+ if (typeof s3Client.getSignedUrl === 'function') {
197
+ const uploadUrl = await s3Client.getSignedUrl('putObject', {
198
+ Bucket: bucket,
199
+ Key: key,
200
+ ContentType: mimeType,
201
+ Expires: expiresInSeconds,
202
+ });
203
+ return {
204
+ uploadUrl,
205
+ publicUrl,
206
+ key,
207
+ method: 'PUT',
208
+ headers: { 'Content-Type': mimeType },
209
+ };
210
+ }
211
+
212
+ // 2. Custom signer function option
213
+ if (typeof options.createSignedUrl === 'function') {
214
+ const uploadUrl = await options.createSignedUrl({ bucket, key, mimeType, expiresInSeconds });
215
+ return {
216
+ uploadUrl,
217
+ publicUrl,
218
+ key,
219
+ method: 'PUT',
220
+ headers: { 'Content-Type': mimeType },
221
+ };
222
+ }
223
+
224
+ // 3. AWS SDK v3 with @aws-sdk/s3-request-presigner
225
+ try {
226
+ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
227
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
228
+ const uploadUrl = await getSignedUrl(
229
+ s3Client,
230
+ new PutObjectCommand({
231
+ Bucket: bucket,
232
+ Key: key,
233
+ ContentType: mimeType,
234
+ }),
235
+ { expiresIn: expiresInSeconds }
236
+ );
237
+ return {
238
+ uploadUrl,
239
+ publicUrl,
240
+ key,
241
+ method: 'PUT',
242
+ headers: { 'Content-Type': mimeType },
243
+ };
244
+ } catch (err) {
245
+ throw new Error(
246
+ `[createS3FileProvider] Direct signed upload requires @aws-sdk/s3-request-presigner or a mock client with getSignedUrl: ${err.message}`
247
+ );
248
+ }
249
+ },
250
+ };
251
+ }
252
+
253
+ module.exports = {
254
+ createR2FileProvider,
255
+ createS3FileProvider,
256
+ generateStorageKey,
257
+ };