@dimah-s3/server 1.0.1 → 1.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 (100) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/express.js +16 -9
  3. package/dist/adapters/express.js.map +1 -1
  4. package/dist/adapters/fastify.js +16 -9
  5. package/dist/adapters/fastify.js.map +1 -1
  6. package/dist/adapters/hono.d.ts +1 -1
  7. package/dist/adapters/hono.js.map +1 -1
  8. package/dist/adapters/node.d.ts +1 -1
  9. package/dist/adapters/node.js +16 -9
  10. package/dist/adapters/node.js.map +1 -1
  11. package/dist/api/index.js +742 -388
  12. package/dist/api/index.js.map +1 -1
  13. package/dist/api/router.d.ts.map +1 -1
  14. package/dist/api/routes/catalog.d.ts +9 -0
  15. package/dist/api/routes/catalog.d.ts.map +1 -0
  16. package/dist/api/routes/confirm.d.ts +3 -4
  17. package/dist/api/routes/confirm.d.ts.map +1 -1
  18. package/dist/api/routes/delete-batch.d.ts +14 -0
  19. package/dist/api/routes/delete-batch.d.ts.map +1 -0
  20. package/dist/api/routes/delete.d.ts +2 -2
  21. package/dist/api/routes/delete.d.ts.map +1 -1
  22. package/dist/api/routes/download.d.ts +8 -8
  23. package/dist/api/routes/download.d.ts.map +1 -1
  24. package/dist/api/routes/file.d.ts +20 -0
  25. package/dist/api/routes/file.d.ts.map +1 -0
  26. package/dist/api/routes/index.d.ts +76 -50
  27. package/dist/api/routes/index.d.ts.map +1 -1
  28. package/dist/api/routes/multipart/abort.d.ts +2 -2
  29. package/dist/api/routes/multipart/abort.d.ts.map +1 -1
  30. package/dist/api/routes/multipart/complete.d.ts +2 -2
  31. package/dist/api/routes/multipart/complete.d.ts.map +1 -1
  32. package/dist/api/routes/multipart/init.d.ts +10 -12
  33. package/dist/api/routes/multipart/init.d.ts.map +1 -1
  34. package/dist/api/routes/multipart/list-parts.d.ts +2 -2
  35. package/dist/api/routes/multipart/list-parts.d.ts.map +1 -1
  36. package/dist/api/routes/multipart/part.d.ts +4 -6
  37. package/dist/api/routes/multipart/part.d.ts.map +1 -1
  38. package/dist/api/routes/upload.d.ts +10 -14
  39. package/dist/api/routes/upload.d.ts.map +1 -1
  40. package/dist/dimah-s3.d.ts +19 -8
  41. package/dist/dimah-s3.d.ts.map +1 -1
  42. package/dist/errors.d.ts +5 -4
  43. package/dist/errors.d.ts.map +1 -1
  44. package/dist/helpers/best-effort.d.ts +6 -0
  45. package/dist/helpers/best-effort.d.ts.map +1 -0
  46. package/dist/helpers/constraints.d.ts +16 -0
  47. package/dist/helpers/constraints.d.ts.map +1 -0
  48. package/dist/helpers/expires.d.ts +2 -2
  49. package/dist/helpers/features.d.ts +12 -29
  50. package/dist/helpers/features.d.ts.map +1 -1
  51. package/dist/helpers/finalize-confirmed.d.ts +19 -0
  52. package/dist/helpers/finalize-confirmed.d.ts.map +1 -0
  53. package/dist/helpers/head-object.d.ts.map +1 -1
  54. package/dist/helpers/hooks.d.ts +7 -1
  55. package/dist/helpers/hooks.d.ts.map +1 -1
  56. package/dist/helpers/index.d.ts +12 -9
  57. package/dist/helpers/index.d.ts.map +1 -1
  58. package/dist/helpers/list-parts.d.ts +5 -0
  59. package/dist/helpers/list-parts.d.ts.map +1 -1
  60. package/dist/helpers/logger.d.ts +4 -0
  61. package/dist/helpers/logger.d.ts.map +1 -0
  62. package/dist/helpers/previous-key.d.ts +5 -0
  63. package/dist/helpers/previous-key.d.ts.map +1 -0
  64. package/dist/helpers/resolve-route.d.ts +36 -0
  65. package/dist/helpers/resolve-route.d.ts.map +1 -0
  66. package/dist/helpers/resolve-target.d.ts +25 -14
  67. package/dist/helpers/resolve-target.d.ts.map +1 -1
  68. package/dist/index.d.ts +7 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1017 -477
  71. package/dist/index.js.map +1 -1
  72. package/dist/plugin/apply-plugins.d.ts +9 -4
  73. package/dist/plugin/apply-plugins.d.ts.map +1 -1
  74. package/dist/plugin/hook-registry.d.ts +13 -4
  75. package/dist/plugin/hook-registry.d.ts.map +1 -1
  76. package/dist/plugin/index.d.ts +1 -2
  77. package/dist/plugin/index.d.ts.map +1 -1
  78. package/dist/plugin/types.d.ts +12 -7
  79. package/dist/plugin/types.d.ts.map +1 -1
  80. package/dist/plugins.d.ts +5 -3
  81. package/dist/plugins.d.ts.map +1 -1
  82. package/dist/plugins.js +951 -475
  83. package/dist/plugins.js.map +1 -1
  84. package/dist/put.d.ts +15 -0
  85. package/dist/put.d.ts.map +1 -0
  86. package/dist/route.d.ts +13 -0
  87. package/dist/route.d.ts.map +1 -0
  88. package/dist/types/config.d.ts +194 -89
  89. package/dist/types/config.d.ts.map +1 -1
  90. package/dist/types/hook-contexts.d.ts +126 -85
  91. package/dist/types/hook-contexts.d.ts.map +1 -1
  92. package/dist/types/index.d.ts +3 -2
  93. package/dist/types/index.d.ts.map +1 -1
  94. package/package.json +11 -11
  95. package/dist/api/assert-feature-enabled.d.ts +0 -5
  96. package/dist/api/assert-feature-enabled.d.ts.map +0 -1
  97. package/dist/helpers/resolve-object-acl.d.ts +0 -10
  98. package/dist/helpers/resolve-object-acl.d.ts.map +0 -1
  99. package/dist/helpers/resolve-request-acl.d.ts +0 -10
  100. package/dist/helpers/resolve-request-acl.d.ts.map +0 -1
package/dist/plugins.js CHANGED
@@ -1,10 +1,28 @@
1
- import { S3_API_ROUTES, isAPIError, DimahS3Error, S3_ERROR_CODES, confirmBodySchema, deleteQuerySchema, downloadQuerySchema, multipartAbortBodySchema, multipartCompleteBodySchema, multipartInitBodySchema, multipartListPartsQuerySchema, multipartSignPartBodySchema, uploadBodySchema, parseFileName, S3_MAX_EXPIRES_IN, buildContentDisposition, S3_MAX_POST_OBJECT_BYTES, S3_DEFAULT_EXPIRES_IN } from '@dimah-s3/core';
2
- import { DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, PutObjectCommand, GetObjectAclCommand, ListPartsCommand, CompleteMultipartUploadCommand, HeadObjectCommand, AbortMultipartUploadCommand } from '@aws-sdk/client-s3';
1
+ import { S3_API_ROUTES, isAPIError, DimahS3Error, S3_ERROR_CODES, confirmBodySchema, deleteQuerySchema, isDimahS3Error, deleteBatchBodySchema, downloadQuerySchema, fileQuerySchema, multipartAbortBodySchema, multipartCompleteBodySchema, multipartInitBodySchema, multipartListPartsQuerySchema, multipartSignPartBodySchema, uploadBodySchema, S3_MAX_EXPIRES_IN, buildContentDisposition, S3_MAX_POST_OBJECT_BYTES, resolveStoredFileName, S3_DEFAULT_EXPIRES_IN, matchesFileTypes, ROUTE_NAME_PATTERN, normalizeObjectKey, sanitizeFileName } from '@dimah-s3/core';
2
+ import { DeleteObjectCommand, GetObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, PutObjectCommand, ListPartsCommand, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
3
3
  import { createMiddleware, createEndpoint } from 'better-call';
4
4
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
5
5
  import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
6
6
 
7
7
  // src/plugin/apply-plugins.ts
8
+ async function deleteObjectBestEffort(client, bucket, key) {
9
+ try {
10
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
11
+ } catch {
12
+ }
13
+ }
14
+ async function abortMultipartBestEffort(client, bucket, key, uploadId) {
15
+ try {
16
+ await client.send(
17
+ new AbortMultipartUploadCommand({
18
+ Bucket: bucket,
19
+ Key: key,
20
+ UploadId: uploadId
21
+ })
22
+ );
23
+ } catch {
24
+ }
25
+ }
8
26
  var errors = {
9
27
  notFound: () => DimahS3Error.from("NOT_FOUND", S3_ERROR_CODES.NOT_FOUND),
10
28
  unauthorized: () => DimahS3Error.from("UNAUTHORIZED", S3_ERROR_CODES.UNAUTHORIZED),
@@ -21,10 +39,18 @@ var errors = {
21
39
  params: { feature }
22
40
  }),
23
41
  invalidKey: () => DimahS3Error.from("BAD_REQUEST", S3_ERROR_CODES.INVALID_KEY),
24
- invalidBucket: (bucket) => DimahS3Error.from("FORBIDDEN", {
25
- code: S3_ERROR_CODES.INVALID_BUCKET.code,
26
- message: S3_ERROR_CODES.INVALID_BUCKET.message,
27
- params: { bucket }
42
+ unknownRoute: (route) => DimahS3Error.from("NOT_FOUND", {
43
+ code: S3_ERROR_CODES.UNKNOWN_ROUTE.code,
44
+ message: S3_ERROR_CODES.UNKNOWN_ROUTE.message,
45
+ params: { route }
46
+ }),
47
+ fileTypeNotAllowed: (fileName, contentType) => DimahS3Error.from("BAD_REQUEST", {
48
+ code: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.code,
49
+ message: S3_ERROR_CODES.FILE_TYPE_NOT_ALLOWED.message,
50
+ params: {
51
+ fileName,
52
+ ...contentType ? { contentType } : {}
53
+ }
28
54
  }),
29
55
  s3NetworkError: (code) => DimahS3Error.from("BAD_GATEWAY", {
30
56
  code: S3_ERROR_CODES.S3_NETWORK_ERROR.code,
@@ -34,11 +60,6 @@ var errors = {
34
60
  ),
35
61
  params: { code }
36
62
  }),
37
- fileSizeRequiredUpload: () => DimahS3Error.from("BAD_REQUEST", S3_ERROR_CODES.FILE_SIZE_REQUIRED_UPLOAD),
38
- fileSizeRequiredMultipart: () => DimahS3Error.from(
39
- "BAD_REQUEST",
40
- S3_ERROR_CODES.FILE_SIZE_REQUIRED_MULTIPART
41
- ),
42
63
  multipartPartMissing: (partNumber) => DimahS3Error.from("BAD_REQUEST", {
43
64
  code: S3_ERROR_CODES.MULTIPART_PART_MISSING.code,
44
65
  message: S3_ERROR_CODES.MULTIPART_PART_MISSING.message.replaceAll(
@@ -51,6 +72,10 @@ var errors = {
51
72
  ...S3_ERROR_CODES.PAYLOAD_TOO_LARGE,
52
73
  message
53
74
  }),
75
+ quotaExceeded: (message = S3_ERROR_CODES.QUOTA_EXCEEDED.message) => DimahS3Error.from("PAYLOAD_TOO_LARGE", {
76
+ ...S3_ERROR_CODES.QUOTA_EXCEEDED,
77
+ message
78
+ }),
54
79
  validationError: (message) => DimahS3Error.from("BAD_REQUEST", {
55
80
  code: S3_ERROR_CODES.VALIDATION_ERROR.code,
56
81
  message
@@ -70,119 +95,366 @@ async function sendOrObjectNotFound(send) {
70
95
  throw err;
71
96
  }
72
97
  }
73
- var DEFAULT_ACL_LOOKUP_TIMEOUT_MS = 1500;
74
- async function resolveObjectAcl(s3, bucket, key, timeoutMs = DEFAULT_ACL_LOOKUP_TIMEOUT_MS) {
75
- const controller = new AbortController();
76
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
77
- try {
78
- const result = await s3.send(
79
- new GetObjectAclCommand({ Bucket: bucket, Key: key }),
80
- { abortSignal: controller.signal }
81
- );
82
- const isPublic = result.Grants?.some(
83
- (g) => g.Grantee?.URI === "http://acs.amazonaws.com/groups/global/AllUsers" && (g.Permission === "READ" || g.Permission === "FULL_CONTROL")
84
- );
85
- return isPublic ? "public-read" : "private";
86
- } catch {
87
- return void 0;
88
- } finally {
89
- clearTimeout(timeout);
90
- }
91
- }
92
-
93
- // src/helpers/resolve-request-acl.ts
94
- function resolveRequestAcl(policy, requested) {
95
- if (policy?.acl) return policy.acl;
96
- if (policy?.allowClientAcl && requested) return requested;
97
- return "private";
98
- }
99
98
  function normalizeExpiresIn(value, maxExpiresIn = S3_MAX_EXPIRES_IN) {
100
99
  const n = Number(value);
101
100
  const expires = Number.isFinite(n) && n > 0 ? Math.floor(n) : S3_DEFAULT_EXPIRES_IN;
102
101
  const max = Number.isFinite(maxExpiresIn) && maxExpiresIn > 0 ? Math.floor(maxExpiresIn) : S3_MAX_EXPIRES_IN;
103
102
  return Math.min(expires, max);
104
103
  }
104
+
105
+ // src/helpers/logger.ts
106
+ var defaultLogger = {
107
+ error: (message, ...args) => {
108
+ console.error("[dimah-s3]", message, ...args);
109
+ },
110
+ warn: (message, ...args) => {
111
+ console.warn("[dimah-s3]", message, ...args);
112
+ }
113
+ };
114
+ function resolveLogger(logger) {
115
+ if (logger === false) return {};
116
+ if (!logger) return defaultLogger;
117
+ return logger;
118
+ }
119
+
120
+ // src/helpers/hooks.ts
121
+ function mapGuardError(err) {
122
+ if (isAPIError(err)) throw err;
123
+ if (err instanceof Error && err.message.trim()) {
124
+ throw new DimahS3Error("FORBIDDEN", {
125
+ message: err.message,
126
+ cause: err
127
+ });
128
+ }
129
+ throw errors.forbidden();
130
+ }
105
131
  async function runHook(hook, context) {
106
132
  if (!hook) return;
107
133
  try {
108
134
  await hook(context);
109
135
  } catch (err) {
110
- if (isAPIError(err)) throw err;
111
- if (err instanceof Error && err.message.trim()) {
112
- throw new DimahS3Error("FORBIDDEN", {
113
- message: err.message,
114
- cause: err
115
- });
116
- }
117
- throw errors.forbidden();
136
+ mapGuardError(err);
118
137
  }
119
138
  }
120
- async function runLifecycleHook(hook, context) {
139
+ async function runObjectHook(hook, context) {
140
+ if (!hook) return;
141
+ try {
142
+ return await hook(context);
143
+ } catch (err) {
144
+ mapGuardError(err);
145
+ }
146
+ }
147
+ async function runLifecycleHook(hook, context, config) {
121
148
  if (!hook) return;
122
149
  try {
123
150
  await hook(context);
124
151
  } catch (err) {
125
152
  if (isAPIError(err)) throw err;
126
- console.error("[S3 API] lifecycle hook failed", err);
153
+ resolveLogger(config?.logger).error?.("lifecycle hook failed", err);
154
+ config?.onError?.(err, { request: context.request });
127
155
  throw errors.internalError();
128
156
  }
129
157
  }
130
158
 
131
- // src/helpers/request.ts
132
- function requestFromHeaders(headers) {
133
- return new Request("http://dimah-s3.local", { headers });
159
+ // src/helpers/previous-key.ts
160
+ var DIMAH_PREVIOUS_KEY_META = "dimah-previous-key";
161
+ function stripPreviousKeyMeta(metadata) {
162
+ if (!metadata) return {};
163
+ const { [DIMAH_PREVIOUS_KEY_META]: _omit, ...rest } = metadata;
164
+ return rest;
165
+ }
166
+ function previousKeyFromMetadata(metadata) {
167
+ const value = metadata?.[DIMAH_PREVIOUS_KEY_META]?.trim();
168
+ return value || void 0;
134
169
  }
135
170
 
136
171
  // src/helpers/resolve-target.ts
137
172
  function assertSafeObjectKey(key) {
138
- const trimmed = key.replace(/^\/+/u, "").replace(/\/+$/u, "");
139
- if (!trimmed) {
140
- throw errors.invalidKey();
141
- }
142
- if (trimmed.includes("\\") || trimmed.includes("\0")) {
143
- throw errors.invalidKey();
144
- }
145
- const parts = trimmed.split("/");
146
- if (parts.some((part) => part === "" || part === "." || part === "..")) {
147
- throw errors.invalidKey();
148
- }
149
- return parts.join("/");
173
+ const normalized = normalizeObjectKey(key);
174
+ if (!normalized) throw errors.invalidKey();
175
+ return normalized;
150
176
  }
151
177
  function applyPrefix(prefix, key) {
152
178
  const p = assertSafeObjectKey(prefix);
153
179
  const k = assertSafeObjectKey(key);
154
- if (k === p || k.startsWith(`${p}/`)) return k;
180
+ if (k.startsWith(`${p}/`)) return k;
181
+ if (k === p) throw errors.invalidKey();
155
182
  return `${p}/${k}`;
156
183
  }
157
- async function resolveObjectKey(policy, context) {
158
- if (policy?.resolveKey) {
159
- return assertSafeObjectKey(await policy.resolveKey(context));
184
+ function nestKeyUnderPrefix(keyPrefix, key) {
185
+ const safe = assertSafeObjectKey(key);
186
+ if (keyPrefix === false) return safe;
187
+ return applyPrefix(keyPrefix, safe);
188
+ }
189
+ function assertStoredKey(key, keyPrefix) {
190
+ const safe = assertSafeObjectKey(key);
191
+ if (keyPrefix === false) return safe;
192
+ const root = assertSafeObjectKey(keyPrefix);
193
+ if (safe.startsWith(`${root}/`)) return safe;
194
+ throw errors.invalidKey();
195
+ }
196
+ function objectKeyFileName(fileName) {
197
+ const sanitized = sanitizeFileName(fileName);
198
+ const leaf = sanitized.split("/").filter((part) => part.length > 0).at(-1);
199
+ if (!leaf || leaf === "." || leaf === "..") return "file";
200
+ return leaf;
201
+ }
202
+ function generateObjectKey(folder, fileName) {
203
+ const leaf = `${crypto.randomUUID()}/${objectKeyFileName(fileName)}`;
204
+ if (folder === false) return assertSafeObjectKey(leaf);
205
+ return applyPrefix(folder, leaf);
206
+ }
207
+ function resolveAcl(info, route) {
208
+ return info?.acl ?? route.upload.acl ?? "private";
209
+ }
210
+ async function resolveUploadTarget(route, context) {
211
+ const bucket = route.bucket;
212
+ const objectContext = {
213
+ ...context,
214
+ bucket,
215
+ keyPrefix: route.keyPrefix
216
+ };
217
+ const info = await runObjectHook(route.upload.object, objectContext) ?? void 0;
218
+ const key = info?.key ? nestKeyUnderPrefix(route.keyPrefix, info.key) : generateObjectKey(
219
+ info?.folder != null && info.folder !== "" ? nestKeyUnderPrefix(route.keyPrefix, info.folder) : route.keyPrefix === false ? false : route.keyPrefix,
220
+ context.file.name
221
+ );
222
+ const metadata = { ...info?.metadata };
223
+ if (info?.previousKey) {
224
+ metadata[DIMAH_PREVIOUS_KEY_META] = nestKeyUnderPrefix(
225
+ route.keyPrefix,
226
+ info.previousKey
227
+ );
160
228
  }
161
- const prefix = typeof policy?.prefix === "function" ? await policy.prefix(context) : policy?.prefix;
162
- if (prefix) {
163
- return applyPrefix(prefix, context.proposedKey);
229
+ return {
230
+ key,
231
+ bucket,
232
+ metadata: Object.keys(metadata).length > 0 ? metadata : info?.metadata,
233
+ acl: resolveAcl(info, route)
234
+ };
235
+ }
236
+ function resolveStoredTarget(route, key) {
237
+ return {
238
+ key: assertStoredKey(key, route.keyPrefix),
239
+ bucket: route.bucket
240
+ };
241
+ }
242
+ function assertRouteName(name) {
243
+ if (!ROUTE_NAME_PATTERN.test(name)) {
244
+ throw new Error(
245
+ `dimahS3 route "${name}" is invalid. Use a letter followed by up to 63 letters, digits, underscores, or hyphens.`
246
+ );
164
247
  }
165
- return assertSafeObjectKey(context.proposedKey);
166
248
  }
167
- function resolveBucket(config, requested) {
168
- if (!requested) return config.bucket;
169
- if (config.allowClientBucket) return requested;
170
- if (config.buckets?.length) {
171
- if (config.buckets.includes(requested)) return requested;
172
- throw errors.invalidBucket(requested);
249
+
250
+ // src/helpers/features.ts
251
+ function isEnabled(feature) {
252
+ return feature.enabled;
253
+ }
254
+ function resolveKeyPrefix(name, value) {
255
+ if (value === false) return false;
256
+ const normalized = normalizeObjectKey(value ?? name);
257
+ if (!normalized) {
258
+ throw new Error(
259
+ `dimahS3 route "${name}": keyPrefix is not a valid object-key prefix.`
260
+ );
173
261
  }
174
- return config.bucket;
262
+ return normalized;
175
263
  }
176
- async function resolveRequestTarget(config, policy, input) {
177
- const bucket = resolveBucket(config, input.bucket);
178
- const key = await resolveObjectKey(policy, {
179
- request: input.request,
180
- proposedKey: input.key,
181
- fileName: input.fileName,
182
- contentType: input.contentType,
183
- bucket
264
+ function isFeatureOn(value, defaultOn = false) {
265
+ if (value === false) return false;
266
+ if (value === void 0) return defaultOn;
267
+ if (value === true) return true;
268
+ if ("enabled" in value && typeof value.enabled === "boolean") {
269
+ return value.enabled;
270
+ }
271
+ return true;
272
+ }
273
+ function normalizeFeature(value, defaultOn = false) {
274
+ if (value === false) return { enabled: false };
275
+ if (value === void 0) {
276
+ if (!defaultOn) return { enabled: false };
277
+ return { enabled: true };
278
+ }
279
+ if (value === true) return { enabled: true };
280
+ return { ...value, enabled: true };
281
+ }
282
+ function skippedPluginIds(plugins) {
283
+ const ids = /* @__PURE__ */ new Set();
284
+ if (!plugins) return ids;
285
+ for (const [id, enabled] of Object.entries(plugins)) {
286
+ if (enabled === false) ids.add(id);
287
+ }
288
+ return ids;
289
+ }
290
+ function splitUpload(upload2) {
291
+ if (upload2 === void 0 || typeof upload2 === "boolean") {
292
+ return { upload: upload2, multipart: void 0 };
293
+ }
294
+ const { multipart, ...rest } = upload2;
295
+ return { upload: rest, multipart };
296
+ }
297
+ function normalizeRoute(name, route, instance) {
298
+ assertRouteName(name);
299
+ const client = route.client ?? instance.client;
300
+ const bucket = route.bucket ?? instance.bucket;
301
+ if (!client) {
302
+ throw new Error(
303
+ `dimahS3 route "${name}": set client on the route or on dimahS3().`
304
+ );
305
+ }
306
+ if (!bucket) {
307
+ throw new Error(
308
+ `dimahS3 route "${name}": set bucket on the route or on dimahS3().`
309
+ );
310
+ }
311
+ const { upload: uploadInput, multipart: multipartInput } = splitUpload(
312
+ route.upload
313
+ );
314
+ const upload2 = normalizeFeature(uploadInput);
315
+ const download2 = normalizeFeature(route.download);
316
+ const deleteFeature = normalizeFeature(route.delete);
317
+ const multipart = normalizeFeature(multipartInput);
318
+ if (!upload2.enabled && !download2.enabled && !deleteFeature.enabled) {
319
+ throw new Error(
320
+ `dimahS3 route "${name}": enable upload, download, or delete.`
321
+ );
322
+ }
323
+ const resolvedUpload = upload2.enabled ? { ...upload2, multipart } : { enabled: false, multipart: { enabled: false } };
324
+ if (resolvedUpload.enabled) {
325
+ const maxFileSize = resolvedUpload.maxFileSize;
326
+ if (maxFileSize != null && (!Number.isFinite(maxFileSize) || maxFileSize <= 0)) {
327
+ throw new Error(
328
+ `dimahS3 route "${name}": maxFileSize must be a positive number.`
329
+ );
330
+ }
331
+ }
332
+ return {
333
+ name,
334
+ client,
335
+ bucket,
336
+ keyPrefix: resolveKeyPrefix(name, route.keyPrefix),
337
+ guard: route.guard,
338
+ skippedPluginIds: skippedPluginIds(route.plugins),
339
+ upload: resolvedUpload,
340
+ download: download2,
341
+ delete: deleteFeature
342
+ };
343
+ }
344
+ function normalizeRoutes(config) {
345
+ const entries = Object.entries(config.routes);
346
+ if (entries.length === 0) {
347
+ throw new Error("dimahS3: at least one route is required.");
348
+ }
349
+ const routes = {};
350
+ for (const [name, route] of entries) {
351
+ routes[name] = normalizeRoute(name, route, config);
352
+ }
353
+ assertDistinctKeyPrefixes(routes);
354
+ return routes;
355
+ }
356
+ function prefixesOverlap(a, b) {
357
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
358
+ }
359
+ function assertDistinctKeyPrefixes(routes) {
360
+ const named = Object.entries(routes).filter(
361
+ (entry) => entry[1].keyPrefix !== false
362
+ );
363
+ for (let i = 0; i < named.length; i++) {
364
+ for (let j = i + 1; j < named.length; j++) {
365
+ const [nameA, routeA] = named[i];
366
+ const [nameB, routeB] = named[j];
367
+ if (prefixesOverlap(routeA.keyPrefix, routeB.keyPrefix)) {
368
+ throw new Error(
369
+ `dimahS3: routes "${nameA}" and "${nameB}" have overlapping keyPrefix values ("${routeA.keyPrefix}" and "${routeB.keyPrefix}").`
370
+ );
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ // src/helpers/request.ts
377
+ function requestFromHeaders(headers) {
378
+ return new Request("http://dimah-s3.local", { headers });
379
+ }
380
+ function assertWithinMaxFileSize(maxFileSize, size) {
381
+ if (typeof maxFileSize === "number" && size > maxFileSize) {
382
+ throw errors.payloadTooLarge();
383
+ }
384
+ }
385
+ function assertDeclaredConstraints(constraints, input) {
386
+ if (constraints?.fileTypes?.length && !matchesFileTypes(input.fileName, input.contentType, constraints.fileTypes)) {
387
+ throw errors.fileTypeNotAllowed(input.fileName, input.contentType);
388
+ }
389
+ assertWithinMaxFileSize(constraints?.maxFileSize, input.fileSize);
390
+ }
391
+ function assertVerifiedConstraints(constraints, input) {
392
+ assertWithinMaxFileSize(constraints?.maxFileSize, input.contentLength);
393
+ const name = input.fileName ?? "";
394
+ if (constraints?.fileTypes?.length && !matchesFileTypes(name, input.contentType, constraints.fileTypes)) {
395
+ throw errors.fileTypeNotAllowed(name || "unknown", input.contentType);
396
+ }
397
+ }
398
+
399
+ // src/helpers/resolve-route.ts
400
+ function getResolvedRoute(config, name) {
401
+ const route = config.routes[name];
402
+ if (!route) {
403
+ throw errors.unknownRoute(name);
404
+ }
405
+ return route;
406
+ }
407
+ function featureIsEnabled(route, feature) {
408
+ if (feature === "multipart") return route.upload.multipart.enabled;
409
+ return route[feature].enabled;
410
+ }
411
+ function assertFeatureEnabled(route, feature) {
412
+ if (!featureIsEnabled(route, feature)) {
413
+ throw errors.featureDisabled(feature);
414
+ }
415
+ }
416
+ async function openRoute(config, name, request, feature) {
417
+ const route = getResolvedRoute(config, name);
418
+ assertFeatureEnabled(route, feature);
419
+ await runHook(route.guard, { request, route: route.name });
420
+ return route;
421
+ }
422
+ function storedObjectContext(request, route, key) {
423
+ return { request, route: route.name, key, bucket: route.bucket };
424
+ }
425
+ async function openUploadTarget(config, input, request, feature) {
426
+ const route = await openRoute(config, input.route, request, feature);
427
+ const fileSize = Math.floor(input.fileSize);
428
+ const fileName = input.fileName;
429
+ assertDeclaredConstraints(route.upload, {
430
+ fileName,
431
+ fileSize,
432
+ contentType: input.contentType
433
+ });
434
+ const target = await resolveUploadTarget(route, {
435
+ request,
436
+ route: route.name,
437
+ file: {
438
+ name: fileName,
439
+ size: fileSize,
440
+ type: input.contentType
441
+ },
442
+ clientMetadata: input.metadata
184
443
  });
185
- return { key, bucket };
444
+ return {
445
+ route,
446
+ ...target,
447
+ stored: storedObjectContext(request, route, target.key)
448
+ };
449
+ }
450
+ async function openStoredTarget(config, input, request, feature) {
451
+ const route = await openRoute(config, input.route, request, feature);
452
+ const target = resolveStoredTarget(route, input.key);
453
+ return {
454
+ route,
455
+ ...target,
456
+ stored: storedObjectContext(request, route, target.key)
457
+ };
186
458
  }
187
459
  async function headObjectOrNotFound(client, bucket, key) {
188
460
  return sendOrObjectNotFound(
@@ -194,7 +466,7 @@ async function headObjectAfterMultipartComplete(client, bucket, key) {
194
466
  () => client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
195
467
  );
196
468
  let head = await send();
197
- for (let attempt = 0; attempt < 4 && !head.ContentLength; attempt++) {
469
+ for (let attempt = 0; attempt < 4 && typeof head.ContentLength !== "number"; attempt++) {
198
470
  await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
199
471
  head = await send();
200
472
  }
@@ -227,61 +499,70 @@ async function listAllParts(client, input) {
227
499
  }
228
500
  return parts;
229
501
  }
230
-
231
- // src/helpers/features.ts
232
- function normalizeFeature(value) {
233
- if (value === void 0) return void 0;
234
- if (value === false) return { enabled: false };
235
- if (value === true) return { enabled: true };
236
- return { ...value, enabled: true };
237
- }
238
- function isFeatureEnabled(config, feature) {
239
- return config[feature]?.enabled === true;
240
- }
241
- function applyMultipartDefault(config, multipartInput) {
242
- let next = config;
243
- if (multipartInput === void 0 && config.upload?.enabled) {
244
- next = {
245
- ...config,
246
- multipart: { ...config.multipart, enabled: true }
247
- };
248
- }
249
- if (next.multipart && next.upload) {
250
- next = {
251
- ...next,
252
- multipart: {
253
- ...next.multipart,
254
- prefix: next.multipart.prefix ?? next.upload.prefix,
255
- resolveKey: next.multipart.resolveKey ?? next.upload.resolveKey,
256
- acl: next.multipart.acl ?? next.upload.acl,
257
- allowClientAcl: next.multipart.allowClientAcl ?? next.upload.allowClientAcl,
258
- requireFileSize: next.multipart.requireFileSize ?? next.upload.requireFileSize
259
- }
260
- };
502
+ function listedPartsByteSize(parts, excludePartNumber) {
503
+ let total = 0;
504
+ for (const part of parts) {
505
+ if (excludePartNumber !== void 0 && part.PartNumber === excludePartNumber) {
506
+ continue;
507
+ }
508
+ total += part.Size ?? 0;
261
509
  }
262
- return next;
510
+ return total;
263
511
  }
264
- function normalizeFeatures(config) {
265
- return {
266
- upload: normalizeFeature(config.upload),
267
- download: normalizeFeature(config.download),
268
- delete: normalizeFeature(config.delete),
269
- multipart: normalizeFeature(config.multipart)
512
+ async function finalizeConfirmedObject(client, bucket, key, input) {
513
+ const { config, route, stored, head, uploadId, acl } = input;
514
+ const contentLength = requireContentLength(head);
515
+ const fileName = resolveStoredFileName(head.ContentDisposition, key);
516
+ const rawMetadata = head.Metadata ?? {};
517
+ try {
518
+ assertVerifiedConstraints(route.upload, {
519
+ fileName,
520
+ contentType: head.ContentType,
521
+ contentLength
522
+ });
523
+ } catch (err) {
524
+ await deleteObjectBestEffort(client, bucket, key);
525
+ throw err;
526
+ }
527
+ const metadata = stripPreviousKeyMeta(rawMetadata);
528
+ const context = {
529
+ ...stored,
530
+ contentType: head.ContentType,
531
+ contentLength,
532
+ eTag: head.ETag?.replace(/"/g, ""),
533
+ metadata,
534
+ acl,
535
+ fileName,
536
+ versionId: head.VersionId,
537
+ lastModified: head.LastModified?.toISOString(),
538
+ uploadId
270
539
  };
271
- }
272
- function assertExclusiveBucketFlags(config) {
273
- if (config.allowClientBucket && config.buckets?.length) {
274
- throw new Error(
275
- "dimahS3: set either allowClientBucket or buckets, not both. allowClientBucket honors any client-sent bucket; buckets is an allowlist."
276
- );
540
+ try {
541
+ await runLifecycleHook(route.upload.onConfirmed, context, config);
542
+ } catch (err) {
543
+ await deleteObjectBestEffort(client, bucket, key);
544
+ throw err;
277
545
  }
278
- }
279
-
280
- // src/api/assert-feature-enabled.ts
281
- function assertFeatureEnabled(config, feature) {
282
- if (!isFeatureEnabled(config, feature)) {
283
- throw errors.featureDisabled(feature);
546
+ const previousKey = previousKeyFromMetadata(rawMetadata);
547
+ if (previousKey && previousKey !== key) {
548
+ try {
549
+ const safe = assertStoredKey(previousKey, route.keyPrefix);
550
+ await deleteObjectBestEffort(client, bucket, safe);
551
+ } catch {
552
+ }
284
553
  }
554
+ return {
555
+ key,
556
+ bucket,
557
+ contentType: context.contentType,
558
+ contentLength: context.contentLength,
559
+ eTag: context.eTag,
560
+ metadata: context.metadata ?? {},
561
+ acl,
562
+ fileName,
563
+ versionId: context.versionId,
564
+ lastModified: context.lastModified
565
+ };
285
566
  }
286
567
  var s3ContextMiddleware = createMiddleware(async (ctx) => {
287
568
  const injected = ctx.context;
@@ -329,138 +610,269 @@ var createS3Endpoint = ((pathOrOptions, optionsOrHandler, maybeHandler) => {
329
610
 
330
611
  // src/api/routes/confirm.ts
331
612
  async function handleConfirm(config, input, request) {
332
- const { key, bucket } = await resolveRequestTarget(config, config.upload, {
613
+ const { route, key, bucket, stored } = await openStoredTarget(
614
+ config,
615
+ input,
333
616
  request,
334
- key: input.key,
335
- bucket: input.bucket
617
+ "upload"
618
+ );
619
+ await runHook(route.upload.confirmGuard, {
620
+ ...stored,
621
+ replace: route.upload.replace
336
622
  });
337
- await runHook(config.upload?.confirmGuard, {
338
- request,
339
- key,
340
- bucket
623
+ const head = await headObjectOrNotFound(route.client, bucket, key);
624
+ return finalizeConfirmedObject(route.client, bucket, key, {
625
+ config,
626
+ route,
627
+ stored,
628
+ head,
629
+ acl: route.upload.acl
341
630
  });
342
- const head = await headObjectOrNotFound(config.client, bucket, key);
343
- const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.client, bucket, key) : void 0;
344
- const fileName = parseFileName(head.ContentDisposition);
345
- const context = {
346
- request,
347
- key,
348
- bucket,
349
- contentType: head.ContentType,
350
- contentLength: requireContentLength(head),
351
- eTag: head.ETag?.replace(/"/g, ""),
352
- metadata: head.Metadata,
353
- acl,
354
- fileName,
355
- versionId: head.VersionId,
356
- lastModified: head.LastModified?.toISOString()
357
- };
358
- await runLifecycleHook(config.upload?.onConfirmed, context);
359
- return {
360
- key,
361
- bucket,
362
- contentType: context.contentType,
363
- contentLength: context.contentLength,
364
- eTag: context.eTag,
365
- metadata: context.metadata ?? {},
366
- acl,
367
- fileName,
368
- versionId: context.versionId,
369
- lastModified: context.lastModified
370
- };
371
631
  }
372
632
  var confirm = createS3Endpoint(
373
633
  S3_API_ROUTES.uploadConfirm,
374
634
  { method: "POST", body: confirmBodySchema },
375
635
  async (ctx) => {
376
- assertFeatureEnabled(ctx.context.config, "upload");
377
636
  return handleConfirm(ctx.context.config, ctx.body, ctx.context.request);
378
637
  }
379
638
  );
380
639
  async function handleDelete(config, input, request) {
381
- const { key, bucket } = await resolveRequestTarget(config, config.delete, {
640
+ const { route, key, bucket, stored } = await openStoredTarget(
641
+ config,
642
+ input,
382
643
  request,
383
- key: input.key,
384
- bucket: input.bucket
385
- });
386
- await runHook(config.delete?.guard, {
387
- request,
388
- key,
389
- bucket
390
- });
391
- await headObjectOrNotFound(config.client, bucket, key);
392
- await config.client.send(
644
+ "delete"
645
+ );
646
+ await runHook(route.delete.guard, stored);
647
+ await headObjectOrNotFound(route.client, bucket, key);
648
+ await route.client.send(
393
649
  new DeleteObjectCommand({ Bucket: bucket, Key: key })
394
650
  );
395
- await runLifecycleHook(config.delete?.onDeleted, { request, key, bucket });
651
+ await runLifecycleHook(route.delete.onDeleted, stored, config);
396
652
  return { success: true, bucket, key };
397
653
  }
398
654
  var deleteObject = createS3Endpoint(
399
655
  S3_API_ROUTES.delete,
400
656
  { method: "DELETE", query: deleteQuerySchema },
401
657
  async (ctx) => {
402
- assertFeatureEnabled(ctx.context.config, "delete");
403
658
  return handleDelete(ctx.context.config, ctx.query, ctx.context.request);
404
659
  }
405
660
  );
661
+ async function deleteOne(config, routeName, key, request) {
662
+ const { route, bucket, stored } = await openStoredTarget(
663
+ config,
664
+ { route: routeName, key },
665
+ request,
666
+ "delete"
667
+ );
668
+ await runHook(route.delete.guard, stored);
669
+ await headObjectOrNotFound(route.client, bucket, stored.key);
670
+ await route.client.send(
671
+ new DeleteObjectCommand({ Bucket: bucket, Key: stored.key })
672
+ );
673
+ await runLifecycleHook(route.delete.onDeleted, stored, config);
674
+ }
675
+ var deleteBatch = createS3Endpoint(
676
+ S3_API_ROUTES.deleteBatch,
677
+ { method: "POST", body: deleteBatchBodySchema },
678
+ async (ctx) => {
679
+ const config = ctx.context.config;
680
+ const request = ctx.context.request;
681
+ const results = [];
682
+ for (const key of ctx.body.keys) {
683
+ try {
684
+ await deleteOne(config, ctx.body.route, key, request);
685
+ results.push({ key, success: true });
686
+ } catch (err) {
687
+ results.push({
688
+ key,
689
+ success: false,
690
+ error: isDimahS3Error(err) ? { code: err.code ?? "INTERNAL_ERROR", message: err.message } : { code: "INTERNAL_ERROR", message: "Internal server error" }
691
+ });
692
+ }
693
+ }
694
+ return { results };
695
+ }
696
+ );
697
+ function fileUrl(request, basePath, query) {
698
+ const url = new URL(request.url);
699
+ const prefix = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
700
+ url.pathname = `${prefix}${S3_API_ROUTES.file}`;
701
+ url.search = "";
702
+ url.searchParams.set("route", query.route);
703
+ url.searchParams.set("key", query.key);
704
+ if (query.fileName) url.searchParams.set("fileName", query.fileName);
705
+ if (query.disposition) url.searchParams.set("disposition", query.disposition);
706
+ return url.toString();
707
+ }
406
708
  async function handleDownload(config, input, request) {
407
- const { key, bucket } = await resolveRequestTarget(config, config.download, {
709
+ const { route, key, bucket, stored } = await openStoredTarget(
710
+ config,
711
+ input,
408
712
  request,
409
- key: input.key,
410
- bucket: input.bucket,
411
- fileName: input.fileName
713
+ "download"
714
+ );
715
+ const resolved = await route.download.resolve?.({
716
+ ...stored,
717
+ fileName: input.fileName,
718
+ disposition: input.disposition ?? route.download.disposition
412
719
  });
413
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
414
- const fileName = input.fileName;
415
- await runHook(config.download?.guard, {
416
- request,
417
- key,
418
- bucket,
419
- fileName
720
+ const fileName = resolved?.fileName ?? input.fileName;
721
+ const disposition = resolved?.disposition ?? input.disposition ?? route.download.disposition ?? "attachment";
722
+ const expiresIn = normalizeExpiresIn(
723
+ resolved?.expiresIn ?? route.download.expiresIn,
724
+ config.maxExpiresIn
725
+ );
726
+ await runHook(route.download.guard, {
727
+ ...stored,
728
+ fileName,
729
+ disposition
420
730
  });
421
- await headObjectOrNotFound(config.client, bucket, key);
422
- const url = await getSignedUrl(
423
- config.client,
731
+ await headObjectOrNotFound(route.client, bucket, key);
732
+ const mode = route.download.mode ?? "presign";
733
+ const contentDisposition = fileName ? buildContentDisposition(fileName, disposition) : disposition;
734
+ const url = mode === "proxy" ? fileUrl(request, config.basePath ?? "/api/s3", {
735
+ route: input.route,
736
+ key,
737
+ fileName,
738
+ disposition
739
+ }) : await getSignedUrl(
740
+ route.client,
424
741
  new GetObjectCommand({
425
742
  Bucket: bucket,
426
743
  Key: key,
427
- ResponseContentDisposition: fileName ? buildContentDisposition(fileName) : "attachment"
744
+ ResponseContentDisposition: contentDisposition
428
745
  }),
429
746
  { expiresIn }
430
747
  );
431
- await runLifecycleHook(config.download?.onPresigned, {
432
- request,
433
- key,
748
+ await runLifecycleHook(
749
+ route.download.onPresigned,
750
+ {
751
+ ...stored,
752
+ fileName,
753
+ url,
754
+ expiresIn: mode === "proxy" ? 0 : expiresIn
755
+ },
756
+ config
757
+ );
758
+ return {
434
759
  bucket,
435
- fileName,
760
+ key,
436
761
  url,
437
- expiresIn
438
- });
439
- return { bucket, key, url, expiresIn };
762
+ expiresIn: mode === "proxy" ? 0 : expiresIn,
763
+ mode,
764
+ disposition
765
+ };
440
766
  }
441
767
  var download = createS3Endpoint(
442
768
  S3_API_ROUTES.download,
443
769
  { method: "GET", query: downloadQuerySchema },
444
770
  async (ctx) => {
445
- assertFeatureEnabled(ctx.context.config, "download");
446
771
  return handleDownload(ctx.context.config, ctx.query, ctx.context.request);
447
772
  }
448
773
  );
449
- async function handleAbort(config, input, request) {
450
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
774
+ function catalogUpload(upload2) {
775
+ if (!isEnabled(upload2)) return { enabled: false };
776
+ return {
777
+ enabled: true,
778
+ fileTypes: upload2.fileTypes,
779
+ maxFileSize: upload2.maxFileSize,
780
+ multipart: isEnabled(upload2.multipart),
781
+ ...upload2.checksum ? { checksum: true } : {},
782
+ ...upload2.replace ? { replace: upload2.replace } : {}
783
+ };
784
+ }
785
+ function catalogDownload(download2) {
786
+ if (!isEnabled(download2)) return { enabled: false };
787
+ return {
788
+ enabled: true,
789
+ ...download2.disposition ? { disposition: download2.disposition } : {},
790
+ ...download2.mode ? { mode: download2.mode } : {}
791
+ };
792
+ }
793
+ function buildRouteCatalog(config) {
794
+ const routes = {};
795
+ for (const [name, route] of Object.entries(config.routes)) {
796
+ routes[name] = {
797
+ upload: catalogUpload(route.upload),
798
+ download: catalogDownload(route.download),
799
+ delete: isEnabled(route.delete) ? { enabled: true } : { enabled: false }
800
+ };
801
+ }
802
+ return { routes };
803
+ }
804
+ var catalog = createS3Endpoint(
805
+ S3_API_ROUTES.catalog,
806
+ { method: "GET" },
807
+ async (ctx) => {
808
+ return buildRouteCatalog(ctx.context.config);
809
+ }
810
+ );
811
+ async function handleFile(config, input, request) {
812
+ const { route, key, bucket, stored } = await openStoredTarget(
813
+ config,
814
+ input,
451
815
  request,
452
- key: input.key,
453
- bucket: input.bucket
816
+ "download"
817
+ );
818
+ const resolved = await route.download.resolve?.({
819
+ ...stored,
820
+ fileName: input.fileName,
821
+ disposition: input.disposition ?? route.download.disposition
454
822
  });
455
- const uploadId = input.uploadId;
456
- await runHook(config.multipart?.abortGuard, {
823
+ const fileName = resolved?.fileName ?? input.fileName;
824
+ const disposition = resolved?.disposition ?? input.disposition ?? route.download.disposition ?? "attachment";
825
+ await runHook(route.download.guard, {
826
+ ...stored,
827
+ fileName,
828
+ disposition
829
+ });
830
+ await headObjectOrNotFound(route.client, bucket, key);
831
+ const result = await sendOrObjectNotFound(
832
+ () => route.client.send(
833
+ new GetObjectCommand({
834
+ Bucket: bucket,
835
+ Key: key,
836
+ ResponseContentDisposition: fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
837
+ })
838
+ )
839
+ );
840
+ const body = result.Body;
841
+ if (!body) throw errors.objectNotFound();
842
+ const stream = "transformToWebStream" in body && typeof body.transformToWebStream === "function" ? body.transformToWebStream() : body;
843
+ const headers = new Headers();
844
+ headers.set("Content-Type", result.ContentType ?? "application/octet-stream");
845
+ headers.set(
846
+ "Content-Disposition",
847
+ fileName ? buildContentDisposition(fileName, disposition) : `${disposition}`
848
+ );
849
+ if (typeof result.ContentLength === "number") {
850
+ headers.set("Content-Length", String(result.ContentLength));
851
+ }
852
+ return new Response(stream, { headers });
853
+ }
854
+ var file = createS3Endpoint(
855
+ S3_API_ROUTES.file,
856
+ { method: "GET", query: fileQuerySchema },
857
+ async (ctx) => {
858
+ return handleFile(ctx.context.config, ctx.query, ctx.context.request);
859
+ }
860
+ );
861
+ async function handleAbort(config, input, request) {
862
+ const { route, key, bucket, stored } = await openStoredTarget(
863
+ config,
864
+ input,
457
865
  request,
458
- key,
459
- bucket,
460
- uploadId
866
+ "multipart"
867
+ );
868
+ const uploadId = input.uploadId;
869
+ await runHook(route.upload.multipart.guard, {
870
+ ...stored,
871
+ uploadId,
872
+ action: "abort"
461
873
  });
462
874
  await sendOrObjectNotFound(
463
- () => config.client.send(
875
+ () => route.client.send(
464
876
  new AbortMultipartUploadCommand({
465
877
  Bucket: bucket,
466
878
  Key: key,
@@ -468,52 +880,68 @@ async function handleAbort(config, input, request) {
468
880
  })
469
881
  )
470
882
  );
471
- await runLifecycleHook(config.multipart?.onAbort, {
472
- request,
473
- key,
474
- bucket,
475
- uploadId
476
- });
883
+ await runLifecycleHook(
884
+ route.upload.multipart.onAbort,
885
+ {
886
+ ...stored,
887
+ uploadId
888
+ },
889
+ config
890
+ );
477
891
  return { bucket, key, uploadId, aborted: true };
478
892
  }
479
893
  var multipartAbort = createS3Endpoint(
480
894
  S3_API_ROUTES.multipartAbort,
481
895
  { method: "POST", body: multipartAbortBodySchema },
482
896
  async (ctx) => {
483
- assertFeatureEnabled(ctx.context.config, "multipart");
484
897
  return handleAbort(ctx.context.config, ctx.body, ctx.context.request);
485
898
  }
486
899
  );
487
900
  async function handleComplete(config, input, request) {
488
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
901
+ const { route, key, bucket, stored } = await openStoredTarget(
902
+ config,
903
+ input,
489
904
  request,
490
- key: input.key,
491
- bucket: input.bucket
492
- });
905
+ "multipart"
906
+ );
493
907
  const uploadId = input.uploadId;
494
- const parts = input.parts.map(({ partNumber }) => partNumber).sort((a, b) => a - b);
908
+ const parts = [
909
+ ...new Set(input.parts.map(({ partNumber }) => partNumber))
910
+ ].sort((a, b) => a - b);
495
911
  const partRefs = parts.map((partNumber) => ({ partNumber }));
496
- await runHook(config.multipart?.completeGuard, {
497
- request,
498
- key,
499
- bucket,
912
+ await runHook(route.upload.multipart.guard, {
913
+ ...stored,
914
+ uploadId,
915
+ action: "complete"
916
+ });
917
+ await runHook(route.upload.confirmGuard, {
918
+ ...stored,
500
919
  uploadId,
501
- parts: partRefs
920
+ parts: partRefs,
921
+ replace: route.upload.replace
502
922
  });
503
- const listedParts = await listAllParts(config.client, {
923
+ const listedParts = await listAllParts(route.client, {
504
924
  bucket,
505
925
  key,
506
926
  uploadId
507
927
  });
928
+ let assembledBytes = 0;
508
929
  const completeParts = parts.map((partNumber) => {
509
930
  const found = listedParts.find((p) => p.PartNumber === partNumber);
510
931
  if (!found?.ETag) {
511
932
  throw errors.multipartPartMissing(partNumber);
512
933
  }
934
+ assembledBytes += found.Size ?? 0;
513
935
  return { PartNumber: partNumber, ETag: found.ETag };
514
936
  });
937
+ try {
938
+ assertWithinMaxFileSize(route.upload.maxFileSize, assembledBytes);
939
+ } catch (err) {
940
+ await abortMultipartBestEffort(route.client, bucket, key, uploadId);
941
+ throw err;
942
+ }
515
943
  const completeResult = await sendOrObjectNotFound(
516
- () => config.client.send(
944
+ () => route.client.send(
517
945
  new CompleteMultipartUploadCommand({
518
946
  Bucket: bucket,
519
947
  Key: key,
@@ -523,273 +951,290 @@ async function handleComplete(config, input, request) {
523
951
  )
524
952
  );
525
953
  const head = await headObjectAfterMultipartComplete(
526
- config.client,
954
+ route.client,
527
955
  bucket,
528
956
  key
529
957
  );
530
- const contentLength = requireContentLength(head);
531
- const contentType = head.ContentType;
532
- const eTag = (head.ETag ?? completeResult.ETag ?? "").replace(/"/g, "");
533
- const metadata = head.Metadata ?? {};
534
- const versionId = head.VersionId;
535
- const lastModified = head.LastModified?.toISOString();
536
- const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.client, bucket, key) : void 0;
537
- const fileName = parseFileName(head.ContentDisposition);
538
- await runLifecycleHook(config.multipart?.onComplete, {
539
- request,
540
- key,
541
- bucket,
958
+ const confirmed = await finalizeConfirmedObject(route.client, bucket, key, {
959
+ config,
960
+ route,
961
+ stored,
962
+ head: {
963
+ ...head,
964
+ ETag: head.ETag ?? completeResult.ETag
965
+ },
542
966
  uploadId,
543
- contentLength,
544
- contentType,
545
- eTag,
546
- metadata,
547
- acl,
548
- fileName,
549
- versionId,
550
- lastModified
967
+ acl: route.upload.acl
551
968
  });
552
- return {
553
- bucket,
554
- key,
555
- uploadId,
556
- contentLength,
557
- contentType,
558
- eTag,
559
- metadata,
560
- acl,
561
- fileName,
562
- versionId,
563
- lastModified
564
- };
969
+ return { ...confirmed, uploadId };
565
970
  }
566
971
  var multipartComplete = createS3Endpoint(
567
972
  S3_API_ROUTES.multipartComplete,
568
973
  { method: "POST", body: multipartCompleteBodySchema },
569
974
  async (ctx) => {
570
- assertFeatureEnabled(ctx.context.config, "multipart");
571
975
  return handleComplete(ctx.context.config, ctx.body, ctx.context.request);
572
976
  }
573
977
  );
574
978
  async function handleMultipartInit(config, input, request) {
575
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
576
- request,
577
- key: input.key,
578
- bucket: input.bucket,
579
- fileName: input.fileName,
580
- contentType: input.contentType
581
- });
582
- const acl = resolveRequestAcl(config.multipart, input.acl);
583
- const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : void 0;
584
- if (config.multipart?.requireFileSize && fileSize === void 0) {
585
- throw errors.fileSizeRequiredMultipart();
586
- }
587
- await runHook(config.multipart?.initGuard, {
979
+ const fileSize = Math.floor(input.fileSize);
980
+ const fileName = input.fileName;
981
+ const { route, key, bucket, metadata, acl, stored } = await openUploadTarget(
982
+ config,
983
+ {
984
+ route: input.route,
985
+ fileName,
986
+ fileSize,
987
+ contentType: input.contentType,
988
+ metadata: input.metadata
989
+ },
588
990
  request,
589
- key,
590
- bucket,
591
- fileSize,
592
- contentType: input.contentType,
593
- metadata: input.metadata,
991
+ "multipart"
992
+ );
993
+ await runHook(route.upload.guard, {
994
+ ...stored,
995
+ file: {
996
+ name: fileName,
997
+ size: fileSize,
998
+ type: input.contentType
999
+ },
1000
+ metadata,
1001
+ clientMetadata: input.metadata,
594
1002
  acl,
595
- fileName: input.fileName
1003
+ replace: route.upload.replace
596
1004
  });
597
- const { UploadId } = await config.client.send(
1005
+ const { UploadId } = await route.client.send(
598
1006
  new CreateMultipartUploadCommand({
599
1007
  Bucket: bucket,
600
1008
  Key: key,
601
1009
  ContentType: input.contentType,
602
- ContentDisposition: input.fileName ? buildContentDisposition(input.fileName) : void 0,
603
- Metadata: input.metadata,
1010
+ ContentDisposition: buildContentDisposition(fileName),
1011
+ Metadata: metadata,
604
1012
  ACL: acl
605
1013
  })
606
1014
  );
607
1015
  if (!UploadId) {
608
1016
  throw errors.internalError();
609
1017
  }
610
- await runLifecycleHook(config.multipart?.onInit, {
611
- request,
612
- key,
613
- bucket,
614
- uploadId: UploadId,
615
- contentType: input.contentType,
616
- fileSize,
617
- metadata: input.metadata,
618
- acl,
619
- fileName: input.fileName
620
- });
1018
+ await runLifecycleHook(
1019
+ route.upload.multipart.onInit,
1020
+ {
1021
+ ...stored,
1022
+ uploadId: UploadId,
1023
+ file: {
1024
+ name: fileName,
1025
+ size: fileSize,
1026
+ type: input.contentType
1027
+ },
1028
+ metadata,
1029
+ clientMetadata: input.metadata,
1030
+ acl,
1031
+ replace: route.upload.replace
1032
+ },
1033
+ config
1034
+ );
621
1035
  return { bucket, key, uploadId: UploadId };
622
1036
  }
623
1037
  var multipartInit = createS3Endpoint(
624
1038
  S3_API_ROUTES.multipartInit,
625
1039
  { method: "POST", body: multipartInitBodySchema },
626
1040
  async (ctx) => {
627
- assertFeatureEnabled(ctx.context.config, "multipart");
628
- ctx.setStatus(201);
629
- return handleMultipartInit(
1041
+ const result = await handleMultipartInit(
630
1042
  ctx.context.config,
631
1043
  ctx.body,
632
1044
  ctx.context.request
633
1045
  );
1046
+ ctx.setStatus(201);
1047
+ return result;
634
1048
  }
635
1049
  );
636
1050
  async function handleListParts(config, input, request) {
637
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
1051
+ const { route, key, bucket, stored } = await openStoredTarget(
1052
+ config,
1053
+ input,
638
1054
  request,
639
- key: input.key,
640
- bucket: input.bucket
641
- });
1055
+ "multipart"
1056
+ );
642
1057
  const uploadId = input.uploadId;
643
- await runHook(config.multipart?.listGuard, {
644
- request,
645
- key,
646
- bucket,
647
- uploadId
648
- });
649
- const listed = await listAllParts(config.client, { bucket, key, uploadId });
650
- const parts = listed.map((p) => ({
651
- partNumber: p.PartNumber ?? 0,
652
- size: p.Size ?? 0,
653
- eTag: (p.ETag ?? "").replace(/"/g, "")
654
- }));
655
- await runLifecycleHook(config.multipart?.onList, {
656
- request,
657
- key,
658
- bucket,
1058
+ await runHook(route.upload.multipart.guard, {
1059
+ ...stored,
659
1060
  uploadId,
660
- parts
1061
+ action: "list"
1062
+ });
1063
+ const listed = await listAllParts(route.client, { bucket, key, uploadId });
1064
+ const parts = listed.flatMap((p) => {
1065
+ if (p.PartNumber == null || p.PartNumber < 1) return [];
1066
+ return [
1067
+ {
1068
+ partNumber: p.PartNumber,
1069
+ size: p.Size ?? 0,
1070
+ eTag: (p.ETag ?? "").replace(/"/g, "")
1071
+ }
1072
+ ];
661
1073
  });
1074
+ await runLifecycleHook(
1075
+ route.upload.multipart.onList,
1076
+ {
1077
+ ...stored,
1078
+ uploadId,
1079
+ parts
1080
+ },
1081
+ config
1082
+ );
662
1083
  return { parts };
663
1084
  }
664
1085
  var multipartListParts = createS3Endpoint(
665
1086
  S3_API_ROUTES.multipartListParts,
666
1087
  { method: "GET", query: multipartListPartsQuerySchema },
667
1088
  async (ctx) => {
668
- assertFeatureEnabled(ctx.context.config, "multipart");
669
1089
  return handleListParts(ctx.context.config, ctx.query, ctx.context.request);
670
1090
  }
671
1091
  );
672
1092
  async function handleSignPart(config, input, request) {
673
- const { key, bucket } = await resolveRequestTarget(config, config.multipart, {
1093
+ const { route, key, bucket, stored } = await openStoredTarget(
1094
+ config,
1095
+ input,
674
1096
  request,
675
- key: input.key,
676
- bucket: input.bucket
677
- });
1097
+ "multipart"
1098
+ );
678
1099
  const uploadId = input.uploadId;
679
1100
  const partNumber = input.partNumber;
680
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
681
- const partSize = typeof input.partSize === "number" && input.partSize > 0 ? Math.floor(input.partSize) : null;
682
- await runHook(config.multipart?.partGuard, {
683
- request,
684
- key,
685
- bucket,
1101
+ const expiresIn = normalizeExpiresIn(
1102
+ route.upload.expiresIn,
1103
+ config.maxExpiresIn
1104
+ );
1105
+ const partSize = Math.floor(input.partSize);
1106
+ await runHook(route.upload.multipart.guard, {
1107
+ ...stored,
686
1108
  uploadId,
1109
+ action: "part",
687
1110
  partNumber,
688
- partSize: partSize ?? void 0
1111
+ partSize
689
1112
  });
690
- const presignedUrl = await getSignedUrl(
691
- config.client,
1113
+ const maxFileSize = route.upload.maxFileSize;
1114
+ assertWithinMaxFileSize(maxFileSize, partSize);
1115
+ if (typeof maxFileSize === "number") {
1116
+ const listed = await listAllParts(route.client, { bucket, key, uploadId });
1117
+ assertWithinMaxFileSize(
1118
+ maxFileSize,
1119
+ listedPartsByteSize(listed, partNumber) + partSize
1120
+ );
1121
+ }
1122
+ const url = await getSignedUrl(
1123
+ route.client,
692
1124
  new UploadPartCommand({
693
1125
  Bucket: bucket,
694
1126
  Key: key,
695
1127
  UploadId: uploadId,
696
1128
  PartNumber: partNumber,
697
- ...partSize !== null ? { ContentLength: partSize } : {}
1129
+ ContentLength: partSize
698
1130
  }),
699
1131
  {
700
1132
  expiresIn,
701
- ...partSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
1133
+ signableHeaders: /* @__PURE__ */ new Set(["content-length"])
702
1134
  }
703
1135
  );
704
1136
  return {
705
- presignedUrl,
1137
+ url,
706
1138
  partNumber,
707
1139
  uploadId,
708
1140
  bucket,
709
1141
  expiresIn,
710
- ...partSize !== null ? { partSize } : {}
1142
+ partSize
711
1143
  };
712
1144
  }
713
1145
  var multipartPart = createS3Endpoint(
714
1146
  S3_API_ROUTES.multipartPart,
715
1147
  { method: "POST", body: multipartSignPartBodySchema },
716
1148
  async (ctx) => {
717
- assertFeatureEnabled(ctx.context.config, "multipart");
718
1149
  return handleSignPart(ctx.context.config, ctx.body, ctx.context.request);
719
1150
  }
720
1151
  );
721
- function putMetadataHeaders(metadata) {
1152
+ function objectUserMetadata(metadata) {
722
1153
  if (!metadata) return {};
723
- const headers = {};
1154
+ const fields = {};
724
1155
  for (const [k, v] of Object.entries(metadata)) {
725
- headers[`x-amz-meta-${k}`] = v;
1156
+ fields[`x-amz-meta-${k}`] = v;
726
1157
  }
727
- return headers;
1158
+ return fields;
728
1159
  }
729
1160
  async function handleUpload(config, input, request) {
730
- const { key, bucket } = await resolveRequestTarget(config, config.upload, {
1161
+ const fileSize = Math.floor(input.fileSize);
1162
+ const fileName = input.fileName;
1163
+ const { route, key, bucket, metadata, acl, stored } = await openUploadTarget(
1164
+ config,
1165
+ {
1166
+ route: input.route,
1167
+ fileName,
1168
+ fileSize,
1169
+ contentType: input.contentType,
1170
+ metadata: input.metadata
1171
+ },
731
1172
  request,
732
- key: input.key,
733
- bucket: input.bucket,
734
- fileName: input.fileName,
735
- contentType: input.contentType
736
- });
737
- const expiresIn = normalizeExpiresIn(input.expiresIn, config.maxExpiresIn);
738
- const acl = resolveRequestAcl(config.upload, input.acl);
739
- const contentType = input.contentType ?? "application/octet-stream";
740
- const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : null;
741
- if (config.upload?.requireFileSize && fileSize === null) {
742
- throw errors.fileSizeRequiredUpload();
1173
+ "upload"
1174
+ );
1175
+ const method = route.upload.method ?? "POST";
1176
+ if (route.upload.checksum && !input.checksum) {
1177
+ throw errors.validationError("Checksum is required for this route");
743
1178
  }
744
- await runHook(config.upload?.guard, {
745
- request,
746
- key,
747
- bucket,
748
- contentType: input.contentType,
749
- fileSize: fileSize ?? void 0,
750
- metadata: input.metadata,
1179
+ if (method === "POST" && fileSize > S3_MAX_POST_OBJECT_BYTES) {
1180
+ throw errors.payloadTooLarge(
1181
+ `POST uploads cannot exceed ${S3_MAX_POST_OBJECT_BYTES} bytes. Enable upload.multipart or set upload.method to "PUT".`
1182
+ );
1183
+ }
1184
+ const expiresIn = normalizeExpiresIn(
1185
+ route.upload.expiresIn,
1186
+ config.maxExpiresIn
1187
+ );
1188
+ const contentType = input.contentType ?? "application/octet-stream";
1189
+ const hookCtx = {
1190
+ ...stored,
1191
+ file: {
1192
+ name: fileName,
1193
+ size: fileSize,
1194
+ type: input.contentType
1195
+ },
1196
+ metadata,
1197
+ clientMetadata: input.metadata,
751
1198
  acl,
752
- fileName: input.fileName
753
- });
754
- const method = config.upload?.method ?? "POST";
1199
+ replace: route.upload.replace
1200
+ };
1201
+ await runHook(route.upload.guard, hookCtx);
755
1202
  if (method === "PUT") {
756
1203
  const putHeaders = {
757
1204
  "Content-Type": contentType,
758
- ...putMetadataHeaders(input.metadata)
1205
+ ...objectUserMetadata(metadata),
1206
+ "Content-Disposition": buildContentDisposition(fileName),
1207
+ ...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
759
1208
  };
760
- if (input.fileName) {
761
- putHeaders["Content-Disposition"] = buildContentDisposition(
762
- input.fileName
763
- );
764
- }
765
1209
  const url2 = await getSignedUrl(
766
- config.client,
1210
+ route.client,
767
1211
  new PutObjectCommand({
768
1212
  Bucket: bucket,
769
1213
  Key: key,
770
1214
  ContentType: contentType,
771
1215
  ACL: acl,
772
- Metadata: input.metadata,
773
- ...input.fileName ? { ContentDisposition: buildContentDisposition(input.fileName) } : {},
774
- ...fileSize !== null ? { ContentLength: fileSize } : {}
1216
+ Metadata: metadata,
1217
+ ContentDisposition: buildContentDisposition(fileName),
1218
+ ContentLength: fileSize,
1219
+ ...input.checksum ? { ChecksumSHA256: input.checksum, ChecksumAlgorithm: "SHA256" } : {}
775
1220
  }),
776
1221
  {
777
1222
  expiresIn,
778
- ...fileSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
1223
+ signableHeaders: /* @__PURE__ */ new Set([
1224
+ "content-length",
1225
+ ...input.checksum ? ["x-amz-checksum-sha256"] : []
1226
+ ])
779
1227
  }
780
1228
  );
781
- await runLifecycleHook(config.upload?.onPresigned, {
782
- request,
783
- key,
784
- bucket,
785
- contentType: input.contentType,
786
- fileSize: fileSize ?? void 0,
787
- metadata: input.metadata,
788
- acl,
789
- fileName: input.fileName,
790
- url: url2,
791
- expiresIn
792
- });
1229
+ await runLifecycleHook(
1230
+ route.upload.onPresigned,
1231
+ {
1232
+ ...hookCtx,
1233
+ url: url2,
1234
+ expiresIn
1235
+ },
1236
+ config
1237
+ );
793
1238
  return {
794
1239
  bucket,
795
1240
  key,
@@ -799,39 +1244,32 @@ async function handleUpload(config, input, request) {
799
1244
  method: "PUT"
800
1245
  };
801
1246
  }
802
- const fields = { acl, "Content-Type": contentType };
803
- if (input.fileName) {
804
- fields["Content-Disposition"] = buildContentDisposition(input.fileName);
805
- }
806
- if (input.metadata) {
807
- for (const [k, v] of Object.entries(input.metadata)) {
808
- fields[`x-amz-meta-${k}`] = v;
809
- }
810
- }
811
- const rangeMin = fileSize ?? 1;
812
- const rangeMax = fileSize ?? void 0;
1247
+ const fields = {
1248
+ acl,
1249
+ "Content-Type": contentType,
1250
+ "Content-Disposition": buildContentDisposition(fileName),
1251
+ ...objectUserMetadata(metadata),
1252
+ ...input.checksum ? { "x-amz-checksum-sha256": input.checksum } : {}
1253
+ };
813
1254
  const { url, fields: signedFields } = await createPresignedPost(
814
- config.client,
1255
+ route.client,
815
1256
  {
816
1257
  Bucket: bucket,
817
1258
  Key: key,
818
- Conditions: rangeMax !== void 0 ? [["content-length-range", rangeMin, rangeMax]] : [["content-length-range", rangeMin, S3_MAX_POST_OBJECT_BYTES]],
1259
+ Conditions: [["content-length-range", fileSize, fileSize]],
819
1260
  Fields: fields,
820
1261
  Expires: expiresIn
821
1262
  }
822
1263
  );
823
- await runLifecycleHook(config.upload?.onPresigned, {
824
- request,
825
- key,
826
- bucket,
827
- contentType: input.contentType,
828
- fileSize: fileSize ?? void 0,
829
- metadata: input.metadata,
830
- acl,
831
- fileName: input.fileName,
832
- url,
833
- expiresIn
834
- });
1264
+ await runLifecycleHook(
1265
+ route.upload.onPresigned,
1266
+ {
1267
+ ...hookCtx,
1268
+ url,
1269
+ expiresIn
1270
+ },
1271
+ config
1272
+ );
835
1273
  return {
836
1274
  bucket,
837
1275
  key,
@@ -845,7 +1283,6 @@ var upload = createS3Endpoint(
845
1283
  S3_API_ROUTES.upload,
846
1284
  { method: "POST", body: uploadBodySchema },
847
1285
  async (ctx) => {
848
- assertFeatureEnabled(ctx.context.config, "upload");
849
1286
  return handleUpload(ctx.context.config, ctx.body, ctx.context.request);
850
1287
  }
851
1288
  );
@@ -855,7 +1292,10 @@ var coreEndpoints = {
855
1292
  upload,
856
1293
  confirm,
857
1294
  download,
1295
+ catalog,
1296
+ file,
858
1297
  delete: deleteObject,
1298
+ deleteBatch,
859
1299
  multipartInit,
860
1300
  multipartPart,
861
1301
  multipartListParts,
@@ -879,19 +1319,24 @@ function chainHooks(...hooks) {
879
1319
  var FEATURE_HOOK_KEYS = {
880
1320
  upload: ["guard", "onPresigned", "confirmGuard", "onConfirmed"],
881
1321
  download: ["guard", "onPresigned"],
882
- delete: ["guard", "onDeleted"],
883
- multipart: [
884
- "initGuard",
885
- "partGuard",
886
- "completeGuard",
887
- "abortGuard",
888
- "listGuard",
889
- "onInit",
890
- "onComplete",
891
- "onAbort",
892
- "onList"
893
- ]
1322
+ delete: ["guard", "onDeleted"]
894
1323
  };
1324
+ var MULTIPART_HOOK_KEYS = [
1325
+ "onInit",
1326
+ "guard",
1327
+ "onAbort",
1328
+ "onList"
1329
+ ];
1330
+ var LIFECYCLE_FEATURE_HOOK_KEYS = {
1331
+ upload: ["onPresigned", "onConfirmed"],
1332
+ download: ["onPresigned"],
1333
+ delete: ["onDeleted"]
1334
+ };
1335
+ var LIFECYCLE_MULTIPART_HOOK_KEYS = [
1336
+ "onInit",
1337
+ "onAbort",
1338
+ "onList"
1339
+ ];
895
1340
 
896
1341
  // src/plugin/types.ts
897
1342
  var RESERVED_PLUGIN_IDS = [
@@ -900,35 +1345,74 @@ var RESERVED_PLUGIN_IDS = [
900
1345
  "context",
901
1346
  "getPlugin",
902
1347
  "$ERROR_CODES",
903
- "$Infer"
1348
+ "$Infer",
1349
+ "upload",
1350
+ "confirm",
1351
+ "download",
1352
+ "catalog",
1353
+ "file",
1354
+ "delete",
1355
+ "deleteBatch",
1356
+ "put",
1357
+ "multipart"
904
1358
  ];
905
1359
 
906
1360
  // src/plugin/apply-plugins.ts
907
- function mergeHookField(pluginHooks, userHook) {
908
- const present = [...pluginHooks, userHook].filter(
909
- (h) => h != null
910
- );
1361
+ function mergeHookField(pluginHooks, userHook, order = "plugins-first") {
1362
+ const present = (order === "user-first" ? [userHook, ...pluginHooks] : [...pluginHooks, userHook]).filter((h) => h != null);
911
1363
  if (present.length === 0) return void 0;
912
1364
  if (present.length === 1) return present[0];
913
1365
  return chainHooks(...present);
914
1366
  }
915
- function mergeFeatureHooks(feature, keys, plugins, userFeature) {
916
- if (!userFeature && plugins.every((p) => !p.hooks?.[feature])) {
917
- return userFeature;
918
- }
919
- const merged = { ...userFeature ?? {} };
1367
+ function mergeHooks(keys, plugins, userFeature, pick, lifecycleKeys = /* @__PURE__ */ new Set()) {
1368
+ const merged = { ...userFeature };
920
1369
  for (const key of keys) {
921
- const fromPlugins = plugins.map(
922
- (p) => p.hooks?.[feature]?.[key]
1370
+ const next = mergeHookField(
1371
+ plugins.map((plugin) => pick(plugin)?.[key]),
1372
+ userFeature[key],
1373
+ lifecycleKeys.has(key) ? "user-first" : "plugins-first"
923
1374
  );
924
- const userHook = userFeature?.[key];
925
- const next = mergeHookField(fromPlugins, userHook);
926
- if (next !== void 0) {
927
- merged[key] = next;
928
- }
1375
+ if (next !== void 0) merged[key] = next;
929
1376
  }
930
1377
  return merged;
931
1378
  }
1379
+ function applyHooksToRoute(route, plugins) {
1380
+ const active = plugins.filter((p) => !route.skippedPluginIds.has(p.id));
1381
+ const upload2 = isEnabled(route.upload) ? {
1382
+ ...mergeHooks(
1383
+ FEATURE_HOOK_KEYS.upload,
1384
+ active,
1385
+ route.upload,
1386
+ (p) => p.hooks?.upload,
1387
+ new Set(LIFECYCLE_FEATURE_HOOK_KEYS.upload)
1388
+ ),
1389
+ multipart: isEnabled(route.upload.multipart) ? mergeHooks(
1390
+ MULTIPART_HOOK_KEYS,
1391
+ active,
1392
+ route.upload.multipart,
1393
+ (p) => p.hooks?.upload?.multipart,
1394
+ new Set(LIFECYCLE_MULTIPART_HOOK_KEYS)
1395
+ ) : route.upload.multipart
1396
+ } : route.upload;
1397
+ return {
1398
+ ...route,
1399
+ upload: upload2,
1400
+ download: isEnabled(route.download) ? mergeHooks(
1401
+ FEATURE_HOOK_KEYS.download,
1402
+ active,
1403
+ route.download,
1404
+ (p) => p.hooks?.download,
1405
+ new Set(LIFECYCLE_FEATURE_HOOK_KEYS.download)
1406
+ ) : route.download,
1407
+ delete: isEnabled(route.delete) ? mergeHooks(
1408
+ FEATURE_HOOK_KEYS.delete,
1409
+ active,
1410
+ route.delete,
1411
+ (p) => p.hooks?.delete,
1412
+ new Set(LIFECYCLE_FEATURE_HOOK_KEYS.delete)
1413
+ ) : route.delete
1414
+ };
1415
+ }
932
1416
  function applyPlugins(config) {
933
1417
  const plugins = config.plugins ?? [];
934
1418
  const seen = /* @__PURE__ */ new Set();
@@ -1017,37 +1501,25 @@ function applyPlugins(config) {
1017
1501
  }
1018
1502
  }
1019
1503
  const { plugins: _omit, ...rest } = config;
1020
- assertExclusiveBucketFlags(rest);
1504
+ const normalized = normalizeRoutes(rest);
1021
1505
  for (const plugin of plugins) {
1022
1506
  plugin.init?.({ config: rest, getPlugin });
1023
1507
  }
1024
- const features = normalizeFeatures(rest);
1025
- const {
1026
- upload: _upload,
1027
- download: _download,
1028
- delete: _delete,
1029
- multipart: _multipart,
1030
- ...base
1031
- } = rest;
1508
+ const routes = {};
1509
+ for (const [name, route] of Object.entries(normalized)) {
1510
+ routes[name] = applyHooksToRoute(route, plugins);
1511
+ }
1032
1512
  const guard = mergeHookField(
1033
1513
  plugins.map((p) => p.hooks?.guard),
1034
1514
  rest.guard
1035
1515
  );
1036
1516
  const merged = {
1037
- ...base,
1038
- ...features,
1517
+ ...rest,
1518
+ routes,
1039
1519
  ...guard !== void 0 ? { guard } : {}
1040
1520
  };
1041
- for (const feature of Object.keys(FEATURE_HOOK_KEYS)) {
1042
- merged[feature] = mergeFeatureHooks(
1043
- feature,
1044
- FEATURE_HOOK_KEYS[feature],
1045
- plugins,
1046
- merged[feature]
1047
- );
1048
- }
1049
1521
  return {
1050
- config: applyMultipartDefault(merged, rest.multipart),
1522
+ config: merged,
1051
1523
  context,
1052
1524
  getPlugin,
1053
1525
  endpoints
@@ -1059,6 +1531,10 @@ function definePlugin(plugin) {
1059
1531
  return plugin;
1060
1532
  }
1061
1533
 
1062
- export { CORE_ENDPOINT_NAMES, FEATURE_HOOK_KEYS, RESERVED_PLUGIN_IDS, applyPlugins, chainHooks, createS3Endpoint, createS3Middleware, definePlugin };
1534
+ // src/types/config.ts
1535
+ var ROUTE_FEATURES = ["upload", "download", "delete"];
1536
+ var ROUTE_OPERATIONS = [...ROUTE_FEATURES, "multipart"];
1537
+
1538
+ export { CORE_ENDPOINT_NAMES, FEATURE_HOOK_KEYS, LIFECYCLE_FEATURE_HOOK_KEYS, LIFECYCLE_MULTIPART_HOOK_KEYS, MULTIPART_HOOK_KEYS, RESERVED_PLUGIN_IDS, ROUTE_FEATURES, ROUTE_OPERATIONS, applyPlugins, chainHooks, createS3Endpoint, createS3Middleware, definePlugin, isFeatureOn };
1063
1539
  //# sourceMappingURL=plugins.js.map
1064
1540
  //# sourceMappingURL=plugins.js.map