@dimah-s3/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/adapters/next.d.ts +18 -0
  4. package/dist/adapters/next.d.ts.map +1 -0
  5. package/dist/adapters/next.js +12 -0
  6. package/dist/adapters/next.js.map +1 -0
  7. package/dist/adapters/node.d.ts +16 -0
  8. package/dist/adapters/node.d.ts.map +1 -0
  9. package/dist/adapters/node.js +116 -0
  10. package/dist/adapters/node.js.map +1 -0
  11. package/dist/api.d.ts +5 -0
  12. package/dist/api.d.ts.map +1 -0
  13. package/dist/dimah-s3.d.ts +36 -0
  14. package/dist/dimah-s3.d.ts.map +1 -0
  15. package/dist/errors.d.ts +34 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/handler.d.ts +5 -0
  18. package/dist/handler.d.ts.map +1 -0
  19. package/dist/helpers/index.d.ts +3 -0
  20. package/dist/helpers/index.d.ts.map +1 -0
  21. package/dist/helpers/is-aws-not-found.d.ts +3 -0
  22. package/dist/helpers/is-aws-not-found.d.ts.map +1 -0
  23. package/dist/helpers/is-aws-not-found.test.d.ts +2 -0
  24. package/dist/helpers/is-aws-not-found.test.d.ts.map +1 -0
  25. package/dist/helpers/resolve-object-acl.d.ts +10 -0
  26. package/dist/helpers/resolve-object-acl.d.ts.map +1 -0
  27. package/dist/index.d.ts +9 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +1047 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/internal-helpers.d.ts +23 -0
  32. package/dist/internal-helpers.d.ts.map +1 -0
  33. package/dist/internal-helpers.test.d.ts +2 -0
  34. package/dist/internal-helpers.test.d.ts.map +1 -0
  35. package/dist/plugin/apply-plugins.d.ts +11 -0
  36. package/dist/plugin/apply-plugins.d.ts.map +1 -0
  37. package/dist/plugin/apply-plugins.test.d.ts +2 -0
  38. package/dist/plugin/apply-plugins.test.d.ts.map +1 -0
  39. package/dist/plugin/chain-hooks.d.ts +18 -0
  40. package/dist/plugin/chain-hooks.d.ts.map +1 -0
  41. package/dist/plugin/create-endpoint.d.ts +20 -0
  42. package/dist/plugin/create-endpoint.d.ts.map +1 -0
  43. package/dist/plugin/define-plugin.d.ts +17 -0
  44. package/dist/plugin/define-plugin.d.ts.map +1 -0
  45. package/dist/plugin/hook-registry.d.ts +12 -0
  46. package/dist/plugin/hook-registry.d.ts.map +1 -0
  47. package/dist/plugin/index.d.ts +8 -0
  48. package/dist/plugin/index.d.ts.map +1 -0
  49. package/dist/plugin/types.d.ts +101 -0
  50. package/dist/plugin/types.d.ts.map +1 -0
  51. package/dist/procedures/confirm.d.ts +8 -0
  52. package/dist/procedures/confirm.d.ts.map +1 -0
  53. package/dist/procedures/delete.d.ts +8 -0
  54. package/dist/procedures/delete.d.ts.map +1 -0
  55. package/dist/procedures/download.d.ts +10 -0
  56. package/dist/procedures/download.d.ts.map +1 -0
  57. package/dist/procedures/multipart/abort.d.ts +13 -0
  58. package/dist/procedures/multipart/abort.d.ts.map +1 -0
  59. package/dist/procedures/multipart/complete.d.ts +12 -0
  60. package/dist/procedures/multipart/complete.d.ts.map +1 -0
  61. package/dist/procedures/multipart/init.d.ts +13 -0
  62. package/dist/procedures/multipart/init.d.ts.map +1 -0
  63. package/dist/procedures/multipart/list-parts.d.ts +9 -0
  64. package/dist/procedures/multipart/list-parts.d.ts.map +1 -0
  65. package/dist/procedures/multipart/part.d.ts +12 -0
  66. package/dist/procedures/multipart/part.d.ts.map +1 -0
  67. package/dist/procedures/registry.d.ts +78 -0
  68. package/dist/procedures/registry.d.ts.map +1 -0
  69. package/dist/procedures/upload.d.ts +14 -0
  70. package/dist/procedures/upload.d.ts.map +1 -0
  71. package/dist/types/config.d.ts +87 -0
  72. package/dist/types/config.d.ts.map +1 -0
  73. package/dist/types/hook-contexts.d.ts +147 -0
  74. package/dist/types/hook-contexts.d.ts.map +1 -0
  75. package/dist/types/index.d.ts +3 -0
  76. package/dist/types/index.d.ts.map +1 -0
  77. package/package.json +72 -0
package/dist/index.js ADDED
@@ -0,0 +1,1047 @@
1
+ import { S3_API_ROUTES, DimahS3Error, parseFileName, buildContentDisposition, S3_ERROR_CODES, pluginEndpointPath, S3_API_BASE_PATH, normalizeS3ApiBasePath } from '@dimah-s3/core';
2
+ export { DimahS3Error } from '@dimah-s3/core';
3
+ import { ListPartsCommand, AbortMultipartUploadCommand, CompleteMultipartUploadCommand, HeadObjectCommand, UploadPartCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, PutObjectCommand, GetObjectAclCommand } from '@aws-sdk/client-s3';
4
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
5
+ import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
6
+
7
+ // src/dimah-s3.ts
8
+ var errors = {
9
+ notFound: () => new DimahS3Error("Not Found", 404, { code: S3_ERROR_CODES.NOT_FOUND }),
10
+ invalidJson: () => new DimahS3Error("Invalid JSON payload", 400, {
11
+ code: S3_ERROR_CODES.INVALID_JSON
12
+ }),
13
+ forbidden: () => new DimahS3Error("Forbidden", 403, { code: S3_ERROR_CODES.FORBIDDEN }),
14
+ internalError: () => new DimahS3Error("Internal server error", 500, {
15
+ code: S3_ERROR_CODES.INTERNAL_ERROR
16
+ }),
17
+ objectNotFound: () => new DimahS3Error("Object not found", 404, {
18
+ code: S3_ERROR_CODES.OBJECT_NOT_FOUND
19
+ }),
20
+ s3NetworkError: (code) => new DimahS3Error(
21
+ `S3 endpoint unreachable (${code}): check your endpoint URL and network connectivity`,
22
+ 502,
23
+ { code: S3_ERROR_CODES.S3_NETWORK_ERROR, params: { code } }
24
+ ),
25
+ fieldRequired: (name) => new DimahS3Error(`${name} is required`, 400, {
26
+ code: S3_ERROR_CODES.FIELD_REQUIRED,
27
+ params: { name }
28
+ }),
29
+ /** Missing `key` on query-string routes (download / delete / list-parts). */
30
+ keyRequired: () => new DimahS3Error("key query parameter is required", 400, {
31
+ code: S3_ERROR_CODES.KEY_REQUIRED
32
+ }),
33
+ uploadIdRequired: () => new DimahS3Error("uploadId is required", 400, {
34
+ code: S3_ERROR_CODES.UPLOAD_ID_REQUIRED
35
+ }),
36
+ partNumberInvalid: () => new DimahS3Error("partNumber must be a positive integer", 400, {
37
+ code: S3_ERROR_CODES.PART_NUMBER_INVALID
38
+ }),
39
+ partsRequired: () => new DimahS3Error("At least one valid part is required", 400, {
40
+ code: S3_ERROR_CODES.PARTS_REQUIRED
41
+ }),
42
+ fileSizeRequiredUpload: () => new DimahS3Error(
43
+ "fileSize is required when upload.requireFileSize is enabled",
44
+ 400,
45
+ { code: S3_ERROR_CODES.FILE_SIZE_REQUIRED_UPLOAD }
46
+ ),
47
+ fileSizeRequiredMultipart: () => new DimahS3Error(
48
+ "fileSize is required when multipart.requireFileSize is enabled",
49
+ 400,
50
+ { code: S3_ERROR_CODES.FILE_SIZE_REQUIRED_MULTIPART }
51
+ )
52
+ };
53
+ function requireString(value, name) {
54
+ const trimmed = typeof value === "string" ? value.trim() : "";
55
+ if (!trimmed) throw errors.fieldRequired(name);
56
+ return trimmed;
57
+ }
58
+ function statusOf(err, fallback) {
59
+ return typeof err?.status === "number" ? err.status : fallback;
60
+ }
61
+ function toDimahS3Error(err, fallbackMessage, fallbackStatus = 403, options) {
62
+ if (err instanceof DimahS3Error) return err;
63
+ const message = err instanceof Error && err.message.trim() ? err.message : fallbackMessage;
64
+ const status = statusOf(err, fallbackStatus);
65
+ return new DimahS3Error(message, status, {
66
+ cause: err instanceof Error ? err : void 0,
67
+ ...{}
68
+ });
69
+ }
70
+ async function parseBody(request) {
71
+ try {
72
+ const body = await request.json();
73
+ return body && typeof body === "object" ? body : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+ function errorJson(err) {
79
+ return Response.json(
80
+ {
81
+ message: err.message,
82
+ ...err.code !== void 0 ? { code: err.code } : {},
83
+ ...err.params !== void 0 ? { params: err.params } : {}
84
+ },
85
+ { status: err.status }
86
+ );
87
+ }
88
+ var NETWORK_CODES = /* @__PURE__ */ new Set([
89
+ "EAI_AGAIN",
90
+ "ECONNREFUSED",
91
+ "ECONNRESET",
92
+ "ETIMEDOUT",
93
+ "ENOTFOUND"
94
+ ]);
95
+ function toErrorResponse(err) {
96
+ if (err instanceof DimahS3Error) {
97
+ return errorJson(err);
98
+ }
99
+ const code = err?.code;
100
+ if (typeof code === "string" && NETWORK_CODES.has(code)) {
101
+ const networkErr = errors.s3NetworkError(code);
102
+ console.error("[S3 API]", networkErr.message, err);
103
+ return errorJson(networkErr);
104
+ }
105
+ console.error("[S3 API]", err);
106
+ return errorJson(errors.internalError());
107
+ }
108
+ function wrapHandler(handler) {
109
+ return async (request) => {
110
+ try {
111
+ return await handler(request);
112
+ } catch (err) {
113
+ return toErrorResponse(err);
114
+ }
115
+ };
116
+ }
117
+ async function runHook(hook, context) {
118
+ if (!hook) return;
119
+ try {
120
+ await hook(context);
121
+ } catch (err) {
122
+ if (err instanceof DimahS3Error) throw err;
123
+ if (err instanceof Error && err.message.trim()) {
124
+ throw toDimahS3Error(err, errors.forbidden().message, 403);
125
+ }
126
+ throw errors.forbidden();
127
+ }
128
+ }
129
+ async function runLifecycleHook(hook, context) {
130
+ if (!hook) return;
131
+ try {
132
+ await hook(context);
133
+ } catch (err) {
134
+ if (err instanceof DimahS3Error) throw err;
135
+ console.error("[S3 API] lifecycle hook failed", err);
136
+ throw errors.internalError();
137
+ }
138
+ }
139
+ function normalizeExpiresIn(value) {
140
+ const n = Number(value);
141
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 600;
142
+ }
143
+ function requestFromHeaders(headers) {
144
+ return new Request("http://dimah-s3.local", { headers });
145
+ }
146
+
147
+ // src/helpers/is-aws-not-found.ts
148
+ function isAwsNotFound(err) {
149
+ const e = err;
150
+ return e?.name === "NoSuchKey" || e?.name === "NotFound" || e?.Code === "NoSuchKey" || e?.Code === "NotFound" || e?.$metadata?.httpStatusCode === 404;
151
+ }
152
+ var DEFAULT_ACL_LOOKUP_TIMEOUT_MS = 1500;
153
+ async function resolveObjectAcl(s3, bucket, key, timeoutMs = DEFAULT_ACL_LOOKUP_TIMEOUT_MS) {
154
+ const controller = new AbortController();
155
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
156
+ try {
157
+ const result = await s3.send(
158
+ new GetObjectAclCommand({ Bucket: bucket, Key: key }),
159
+ { abortSignal: controller.signal }
160
+ );
161
+ const isPublic = result.Grants?.some(
162
+ (g) => g.Grantee?.URI === "http://acs.amazonaws.com/groups/global/AllUsers" && (g.Permission === "READ" || g.Permission === "FULL_CONTROL")
163
+ );
164
+ return isPublic ? "public-read" : "private";
165
+ } catch {
166
+ return void 0;
167
+ } finally {
168
+ clearTimeout(timeout);
169
+ }
170
+ }
171
+
172
+ // src/procedures/confirm.ts
173
+ async function confirm(config, input, request) {
174
+ const key = requireString(input.key, "key");
175
+ const bucket = input.bucket?.trim() || config.defaultBucket;
176
+ await runHook(config.upload?.confirmGuard, {
177
+ request,
178
+ key,
179
+ bucket
180
+ });
181
+ const head = await config.s3.send(
182
+ new HeadObjectCommand({ Bucket: bucket, Key: key })
183
+ );
184
+ const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.s3, bucket, key) : void 0;
185
+ const fileName = parseFileName(head.ContentDisposition);
186
+ const context = {
187
+ request,
188
+ key,
189
+ bucket,
190
+ contentType: head.ContentType,
191
+ contentLength: head.ContentLength ?? 0,
192
+ eTag: head.ETag?.replace(/"/g, ""),
193
+ metadata: head.Metadata,
194
+ acl,
195
+ fileName,
196
+ versionId: head.VersionId,
197
+ lastModified: head.LastModified?.toISOString()
198
+ };
199
+ await runLifecycleHook(config.upload?.onConfirmed, context);
200
+ return {
201
+ key,
202
+ bucket,
203
+ contentType: context.contentType,
204
+ contentLength: context.contentLength,
205
+ eTag: context.eTag,
206
+ metadata: context.metadata ?? {},
207
+ acl,
208
+ fileName,
209
+ versionId: context.versionId,
210
+ lastModified: context.lastModified
211
+ };
212
+ }
213
+ async function deleteObject(config, input, request) {
214
+ const key = input.key?.trim();
215
+ if (!key) {
216
+ throw errors.keyRequired();
217
+ }
218
+ const bucket = input.bucket?.trim() || config.defaultBucket;
219
+ await runHook(config.delete?.guard, {
220
+ request,
221
+ key,
222
+ bucket
223
+ });
224
+ try {
225
+ await config.s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
226
+ } catch (err) {
227
+ if (isAwsNotFound(err)) {
228
+ throw errors.objectNotFound();
229
+ }
230
+ throw err;
231
+ }
232
+ await config.s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
233
+ await runLifecycleHook(config.delete?.onDeleted, { request, key, bucket });
234
+ return { success: true, bucket, key };
235
+ }
236
+ async function download(config, input, request) {
237
+ const key = input.key?.trim();
238
+ if (!key) {
239
+ throw errors.keyRequired();
240
+ }
241
+ const bucket = input.bucket?.trim() || config.defaultBucket;
242
+ const expiresIn = normalizeExpiresIn(input.expiresIn);
243
+ const fileName = input.fileName?.trim();
244
+ await runHook(config.download?.presignGuard, {
245
+ request,
246
+ key,
247
+ bucket,
248
+ fileName: fileName || void 0
249
+ });
250
+ try {
251
+ await config.s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
252
+ } catch (err) {
253
+ if (isAwsNotFound(err)) {
254
+ throw errors.objectNotFound();
255
+ }
256
+ throw err;
257
+ }
258
+ const url = await getSignedUrl(
259
+ config.s3,
260
+ new GetObjectCommand({
261
+ Bucket: bucket,
262
+ Key: key,
263
+ ResponseContentDisposition: fileName ? buildContentDisposition(fileName) : "attachment"
264
+ }),
265
+ { expiresIn }
266
+ );
267
+ await runLifecycleHook(config.download?.onPresigned, {
268
+ request,
269
+ key,
270
+ bucket,
271
+ fileName: fileName || void 0,
272
+ url,
273
+ expiresIn
274
+ });
275
+ return { bucket, key, url, expiresIn };
276
+ }
277
+ async function multipartAbort(config, input, request) {
278
+ const key = requireString(input.key, "key");
279
+ const uploadId = requireString(input.uploadId, "uploadId");
280
+ const bucket = input.bucket?.trim() || config.defaultBucket;
281
+ await runHook(config.multipart?.abortGuard, {
282
+ request,
283
+ key,
284
+ bucket,
285
+ uploadId
286
+ });
287
+ await config.s3.send(
288
+ new AbortMultipartUploadCommand({
289
+ Bucket: bucket,
290
+ Key: key,
291
+ UploadId: uploadId
292
+ })
293
+ );
294
+ await runLifecycleHook(config.multipart?.onAbort, {
295
+ request,
296
+ key,
297
+ bucket,
298
+ uploadId
299
+ });
300
+ return { bucket, key, uploadId, aborted: true };
301
+ }
302
+ async function multipartComplete(config, input, request) {
303
+ const key = requireString(input.key, "key");
304
+ const uploadId = requireString(input.uploadId, "uploadId");
305
+ const parts = (Array.isArray(input.parts) ? input.parts : []).map(({ partNumber }) => Number(partNumber)).filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b);
306
+ if (!parts.length) {
307
+ throw errors.partsRequired();
308
+ }
309
+ const bucket = input.bucket?.trim() || config.defaultBucket;
310
+ const partRefs = parts.map((partNumber) => ({ partNumber }));
311
+ await runHook(config.multipart?.completeGuard, {
312
+ request,
313
+ key,
314
+ bucket,
315
+ uploadId,
316
+ parts: partRefs
317
+ });
318
+ const listed = await config.s3.send(
319
+ new ListPartsCommand({ Bucket: bucket, Key: key, UploadId: uploadId })
320
+ );
321
+ const listedParts = listed.Parts ?? [];
322
+ const completeParts = parts.map((partNumber) => {
323
+ const found = listedParts.find((p) => p.PartNumber === partNumber);
324
+ return { PartNumber: partNumber, ETag: found?.ETag ?? "" };
325
+ });
326
+ const completeResult = await config.s3.send(
327
+ new CompleteMultipartUploadCommand({
328
+ Bucket: bucket,
329
+ Key: key,
330
+ UploadId: uploadId,
331
+ MultipartUpload: { Parts: completeParts }
332
+ })
333
+ );
334
+ let head = await config.s3.send(
335
+ new HeadObjectCommand({ Bucket: bucket, Key: key })
336
+ );
337
+ for (let attempt = 0; attempt < 4 && !head.ContentLength; attempt++) {
338
+ await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
339
+ head = await config.s3.send(
340
+ new HeadObjectCommand({ Bucket: bucket, Key: key })
341
+ );
342
+ }
343
+ const contentLength = head.ContentLength ?? 0;
344
+ const contentType = head.ContentType;
345
+ const eTag = (head.ETag ?? completeResult.ETag ?? "").replace(/"/g, "");
346
+ const metadata = head.Metadata ?? {};
347
+ const versionId = head.VersionId;
348
+ const lastModified = head.LastModified?.toISOString();
349
+ const acl = config.resolveObjectAcl ? await resolveObjectAcl(config.s3, bucket, key) : void 0;
350
+ const fileName = parseFileName(head.ContentDisposition);
351
+ await runLifecycleHook(config.multipart?.onComplete, {
352
+ request,
353
+ key,
354
+ bucket,
355
+ uploadId,
356
+ contentLength,
357
+ contentType,
358
+ eTag,
359
+ metadata,
360
+ acl,
361
+ fileName,
362
+ versionId,
363
+ lastModified
364
+ });
365
+ return {
366
+ bucket,
367
+ key,
368
+ uploadId,
369
+ contentLength,
370
+ contentType,
371
+ eTag,
372
+ metadata,
373
+ acl,
374
+ fileName,
375
+ versionId,
376
+ lastModified
377
+ };
378
+ }
379
+ async function multipartInit(config, input, request) {
380
+ const key = requireString(input.key, "key");
381
+ const bucket = input.bucket?.trim() || config.defaultBucket;
382
+ const acl = input.acl === "public-read" ? "public-read" : "private";
383
+ const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : void 0;
384
+ if (config.multipart?.requireFileSize && fileSize === void 0) {
385
+ throw errors.fileSizeRequiredMultipart();
386
+ }
387
+ await runHook(config.multipart?.initGuard, {
388
+ request,
389
+ key,
390
+ bucket,
391
+ fileSize
392
+ });
393
+ const { UploadId } = await config.s3.send(
394
+ new CreateMultipartUploadCommand({
395
+ Bucket: bucket,
396
+ Key: key,
397
+ ContentType: input.contentType,
398
+ ContentDisposition: input.fileName ? buildContentDisposition(input.fileName) : void 0,
399
+ Metadata: input.metadata,
400
+ ACL: acl
401
+ })
402
+ );
403
+ await runLifecycleHook(config.multipart?.onInit, {
404
+ request,
405
+ key,
406
+ bucket,
407
+ uploadId: UploadId,
408
+ contentType: input.contentType,
409
+ fileSize,
410
+ metadata: input.metadata,
411
+ acl,
412
+ fileName: input.fileName
413
+ });
414
+ return { bucket, key, uploadId: UploadId };
415
+ }
416
+ async function multipartListParts(config, input, request) {
417
+ const key = input.key?.trim() ?? "";
418
+ const uploadId = input.uploadId?.trim() ?? "";
419
+ const bucketParam = input.bucket?.trim();
420
+ if (!key) {
421
+ throw errors.keyRequired();
422
+ }
423
+ if (!uploadId) {
424
+ throw errors.uploadIdRequired();
425
+ }
426
+ const bucket = bucketParam || config.defaultBucket;
427
+ await runHook(config.multipart?.listGuard, {
428
+ request,
429
+ key,
430
+ bucket,
431
+ uploadId
432
+ });
433
+ const response = await config.s3.send(
434
+ new ListPartsCommand({
435
+ Bucket: bucket,
436
+ Key: key,
437
+ UploadId: uploadId
438
+ })
439
+ );
440
+ const parts = (response.Parts ?? []).map((p) => ({
441
+ partNumber: p.PartNumber ?? 0,
442
+ size: p.Size ?? 0,
443
+ eTag: (p.ETag ?? "").replace(/"/g, "")
444
+ }));
445
+ await runLifecycleHook(config.multipart?.onList, {
446
+ request,
447
+ key,
448
+ bucket,
449
+ uploadId,
450
+ parts
451
+ });
452
+ return { parts };
453
+ }
454
+ async function multipartSignPart(config, input, request) {
455
+ const key = requireString(input.key, "key");
456
+ const uploadId = requireString(input.uploadId, "uploadId");
457
+ const partNumber = Number(input.partNumber);
458
+ if (!Number.isInteger(partNumber) || partNumber <= 0) {
459
+ throw errors.partNumberInvalid();
460
+ }
461
+ const bucket = input.bucket?.trim() || config.defaultBucket;
462
+ const expiresIn = normalizeExpiresIn(input.expiresIn);
463
+ const partSize = typeof input.partSize === "number" && input.partSize > 0 ? Math.floor(input.partSize) : null;
464
+ await runHook(config.multipart?.partGuard, {
465
+ request,
466
+ key,
467
+ bucket,
468
+ uploadId,
469
+ partNumber,
470
+ partSize: partSize ?? void 0
471
+ });
472
+ const presignedUrl = await getSignedUrl(
473
+ config.s3,
474
+ new UploadPartCommand({
475
+ Bucket: bucket,
476
+ Key: key,
477
+ UploadId: uploadId,
478
+ PartNumber: partNumber,
479
+ ...partSize !== null ? { ContentLength: partSize } : {}
480
+ }),
481
+ {
482
+ expiresIn,
483
+ ...partSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
484
+ }
485
+ );
486
+ return {
487
+ presignedUrl,
488
+ partNumber,
489
+ uploadId,
490
+ bucket,
491
+ expiresIn,
492
+ ...partSize !== null ? { partSize } : {}
493
+ };
494
+ }
495
+ async function upload(config, input, request) {
496
+ const key = requireString(input.key, "key");
497
+ const bucket = input.bucket?.trim() || config.defaultBucket;
498
+ const expiresIn = normalizeExpiresIn(input.expiresIn);
499
+ const acl = input.acl === "public-read" ? "public-read" : "private";
500
+ const contentType = input.contentType?.trim() || "application/octet-stream";
501
+ const fileSize = typeof input.fileSize === "number" && input.fileSize > 0 ? Math.floor(input.fileSize) : null;
502
+ await runHook(config.upload?.presignGuard, {
503
+ request,
504
+ key,
505
+ bucket,
506
+ contentType: input.contentType,
507
+ fileSize: fileSize ?? void 0,
508
+ metadata: input.metadata,
509
+ acl,
510
+ fileName: input.fileName
511
+ });
512
+ const method = config.upload?.method ?? "POST";
513
+ if (method === "PUT" && config.upload?.requireFileSize && fileSize === null) {
514
+ throw errors.fileSizeRequiredUpload();
515
+ }
516
+ if (method === "PUT") {
517
+ const putHeaders = {
518
+ "Content-Type": contentType
519
+ };
520
+ if (input.fileName) {
521
+ putHeaders["Content-Disposition"] = buildContentDisposition(
522
+ input.fileName
523
+ );
524
+ }
525
+ const url2 = await getSignedUrl(
526
+ config.s3,
527
+ new PutObjectCommand({
528
+ Bucket: bucket,
529
+ Key: key,
530
+ ContentType: contentType,
531
+ ACL: acl,
532
+ ...input.fileName ? { ContentDisposition: buildContentDisposition(input.fileName) } : {},
533
+ ...fileSize !== null ? { ContentLength: fileSize } : {}
534
+ }),
535
+ {
536
+ expiresIn,
537
+ ...fileSize !== null ? { signableHeaders: /* @__PURE__ */ new Set(["content-length"]) } : {}
538
+ }
539
+ );
540
+ await runLifecycleHook(config.upload?.onPresigned, {
541
+ request,
542
+ key,
543
+ bucket,
544
+ contentType: input.contentType,
545
+ fileSize: fileSize ?? void 0,
546
+ metadata: input.metadata,
547
+ acl,
548
+ fileName: input.fileName,
549
+ url: url2,
550
+ expiresIn
551
+ });
552
+ return {
553
+ bucket,
554
+ key,
555
+ url: url2,
556
+ headers: putHeaders,
557
+ expiresIn,
558
+ method: "PUT"
559
+ };
560
+ }
561
+ const fields = { acl, "Content-Type": contentType };
562
+ if (input.fileName) {
563
+ fields["Content-Disposition"] = buildContentDisposition(input.fileName);
564
+ }
565
+ if (input.metadata) {
566
+ for (const [k, v] of Object.entries(input.metadata)) {
567
+ fields[`x-amz-meta-${k}`] = v;
568
+ }
569
+ }
570
+ const rangeMin = fileSize ?? 1;
571
+ const rangeMax = fileSize ?? void 0;
572
+ const { url, fields: signedFields } = await createPresignedPost(config.s3, {
573
+ Bucket: bucket,
574
+ Key: key,
575
+ Conditions: rangeMax !== void 0 ? [["content-length-range", rangeMin, rangeMax]] : [["content-length-range", rangeMin, Number.MAX_SAFE_INTEGER]],
576
+ Fields: fields,
577
+ Expires: expiresIn
578
+ });
579
+ await runLifecycleHook(config.upload?.onPresigned, {
580
+ request,
581
+ key,
582
+ bucket,
583
+ contentType: input.contentType,
584
+ fileSize: fileSize ?? void 0,
585
+ metadata: input.metadata,
586
+ acl,
587
+ fileName: input.fileName,
588
+ url,
589
+ expiresIn
590
+ });
591
+ return {
592
+ bucket,
593
+ key,
594
+ url,
595
+ fields: signedFields,
596
+ expiresIn,
597
+ method: "POST"
598
+ };
599
+ }
600
+
601
+ // src/procedures/registry.ts
602
+ var PROCEDURE_REGISTRY = {
603
+ upload: {
604
+ path: S3_API_ROUTES.upload,
605
+ method: "POST",
606
+ isEnabled: (c) => c.upload?.enabled,
607
+ run: (config, input, request) => upload(config, input, request)
608
+ },
609
+ uploadConfirm: {
610
+ path: S3_API_ROUTES.uploadConfirm,
611
+ method: "POST",
612
+ isEnabled: (c) => c.upload?.enabled,
613
+ run: (config, input, request) => confirm(config, input, request)
614
+ },
615
+ download: {
616
+ path: S3_API_ROUTES.download,
617
+ method: "GET",
618
+ isEnabled: (c) => c.download?.enabled,
619
+ run: (config, input, request) => download(config, input, request)
620
+ },
621
+ delete: {
622
+ path: S3_API_ROUTES.delete,
623
+ method: "DELETE",
624
+ isEnabled: (c) => c.delete?.enabled,
625
+ run: (config, input, request) => deleteObject(
626
+ config,
627
+ input,
628
+ request
629
+ )
630
+ },
631
+ multipartInit: {
632
+ path: S3_API_ROUTES.multipartInit,
633
+ method: "POST",
634
+ status: 201,
635
+ isEnabled: (c) => c.multipart?.enabled,
636
+ run: (config, input, request) => multipartInit(
637
+ config,
638
+ input,
639
+ request
640
+ )
641
+ },
642
+ multipartPart: {
643
+ path: S3_API_ROUTES.multipartPart,
644
+ method: "POST",
645
+ isEnabled: (c) => c.multipart?.enabled,
646
+ run: (config, input, request) => multipartSignPart(
647
+ config,
648
+ input,
649
+ request
650
+ )
651
+ },
652
+ multipartComplete: {
653
+ path: S3_API_ROUTES.multipartComplete,
654
+ method: "POST",
655
+ isEnabled: (c) => c.multipart?.enabled,
656
+ run: (config, input, request) => multipartComplete(
657
+ config,
658
+ input,
659
+ request
660
+ )
661
+ },
662
+ multipartAbort: {
663
+ path: S3_API_ROUTES.multipartAbort,
664
+ method: "POST",
665
+ isEnabled: (c) => c.multipart?.enabled,
666
+ run: (config, input, request) => multipartAbort(
667
+ config,
668
+ input,
669
+ request
670
+ )
671
+ },
672
+ multipartListParts: {
673
+ path: S3_API_ROUTES.multipartListParts,
674
+ method: "GET",
675
+ isEnabled: (c) => c.multipart?.enabled,
676
+ run: (config, input, request) => multipartListParts(
677
+ config,
678
+ input,
679
+ request
680
+ )
681
+ }
682
+ };
683
+
684
+ // src/api.ts
685
+ async function runProcedure(config, entry, headers, input) {
686
+ const request = requestFromHeaders(headers);
687
+ await runHook(config.guard, { request });
688
+ if (!entry.isEnabled(config)) {
689
+ throw errors.notFound();
690
+ }
691
+ return entry.run(config, input, request);
692
+ }
693
+ function createServerApi(config) {
694
+ return {
695
+ upload: (payload) => {
696
+ const { headers, ...input } = payload;
697
+ return runProcedure(config, PROCEDURE_REGISTRY.upload, headers, input);
698
+ },
699
+ confirm: (payload) => {
700
+ const { headers, ...input } = payload;
701
+ return runProcedure(
702
+ config,
703
+ PROCEDURE_REGISTRY.uploadConfirm,
704
+ headers,
705
+ input
706
+ );
707
+ },
708
+ download: (key, options) => {
709
+ const { headers, ...rest } = options ?? {};
710
+ return runProcedure(config, PROCEDURE_REGISTRY.download, headers, {
711
+ key,
712
+ ...rest
713
+ });
714
+ },
715
+ delete: (key, options) => {
716
+ const { headers, ...rest } = options ?? {};
717
+ return runProcedure(config, PROCEDURE_REGISTRY.delete, headers, {
718
+ key,
719
+ ...rest
720
+ });
721
+ },
722
+ multipart: {
723
+ init: (payload) => {
724
+ const { headers, ...input } = payload;
725
+ return runProcedure(
726
+ config,
727
+ PROCEDURE_REGISTRY.multipartInit,
728
+ headers,
729
+ input
730
+ );
731
+ },
732
+ signPart: (payload) => {
733
+ const { headers, ...input } = payload;
734
+ return runProcedure(
735
+ config,
736
+ PROCEDURE_REGISTRY.multipartPart,
737
+ headers,
738
+ input
739
+ );
740
+ },
741
+ listParts: (payload) => {
742
+ const { headers, ...input } = payload;
743
+ return runProcedure(
744
+ config,
745
+ PROCEDURE_REGISTRY.multipartListParts,
746
+ headers,
747
+ input
748
+ );
749
+ },
750
+ complete: (payload) => {
751
+ const { headers, ...input } = payload;
752
+ return runProcedure(
753
+ config,
754
+ PROCEDURE_REGISTRY.multipartComplete,
755
+ headers,
756
+ input
757
+ );
758
+ },
759
+ abort: (payload) => {
760
+ const { headers, ...input } = payload;
761
+ return runProcedure(
762
+ config,
763
+ PROCEDURE_REGISTRY.multipartAbort,
764
+ headers,
765
+ input
766
+ );
767
+ }
768
+ }
769
+ };
770
+ }
771
+ async function requireJson(request) {
772
+ const body = await parseBody(request);
773
+ if (!body) {
774
+ throw errors.invalidJson();
775
+ }
776
+ return body;
777
+ }
778
+ function decodeInput(entry, ctx) {
779
+ const { request, url } = ctx;
780
+ const { searchParams } = url;
781
+ switch (entry.path) {
782
+ case PROCEDURE_REGISTRY.download.path: {
783
+ const expiresRaw = searchParams.get("expiresIn");
784
+ return Promise.resolve({
785
+ key: searchParams.get("key")?.trim() ?? "",
786
+ bucket: searchParams.get("bucket")?.trim() || void 0,
787
+ fileName: searchParams.get("fileName")?.trim() || void 0,
788
+ expiresIn: expiresRaw != null ? Number(expiresRaw) : void 0
789
+ });
790
+ }
791
+ case PROCEDURE_REGISTRY.delete.path:
792
+ return Promise.resolve({
793
+ key: searchParams.get("key")?.trim() ?? "",
794
+ bucket: searchParams.get("bucket")?.trim() || void 0
795
+ });
796
+ case PROCEDURE_REGISTRY.multipartListParts.path:
797
+ return Promise.resolve({
798
+ key: searchParams.get("key")?.trim() ?? "",
799
+ uploadId: searchParams.get("uploadId")?.trim() ?? "",
800
+ bucket: searchParams.get("bucket")?.trim() || void 0
801
+ });
802
+ default:
803
+ return requireJson(request);
804
+ }
805
+ }
806
+ var routes = Object.values(PROCEDURE_REGISTRY).map((entry) => ({
807
+ method: entry.method,
808
+ path: entry.path,
809
+ enabled: entry.isEnabled,
810
+ status: "status" in entry ? entry.status : void 0,
811
+ run: async (ctx) => {
812
+ const input = await decodeInput(entry, ctx);
813
+ return entry.run(ctx.config, input, ctx.request);
814
+ }
815
+ }));
816
+ function createHandler(config, basePath = S3_API_BASE_PATH, pluginEndpoints = []) {
817
+ const base = normalizeS3ApiBasePath(basePath);
818
+ const pluginRoutes = pluginEndpoints.map((e) => ({
819
+ method: e.method,
820
+ path: pluginEndpointPath(e.pluginId, e.path),
821
+ status: e.status,
822
+ enabled: () => true,
823
+ run: ({ config: cfg, request, url }) => e.handler({
824
+ request,
825
+ url,
826
+ config: cfg,
827
+ errors,
828
+ json: () => parseBody(request)
829
+ })
830
+ }));
831
+ const allRoutes = [...routes, ...pluginRoutes];
832
+ return wrapHandler(async (request) => {
833
+ await runHook(config.guard, { request });
834
+ const url = new URL(request.url);
835
+ const subpath = url.pathname.slice(base.length).replace(/^\//, "");
836
+ const route = allRoutes.find(
837
+ (r) => r.method === request.method && r.path === subpath
838
+ );
839
+ if (!route || !route.enabled(config)) {
840
+ throw errors.notFound();
841
+ }
842
+ const data = await route.run({ config, request, url, errors });
843
+ return Response.json(
844
+ data,
845
+ route.status ? { status: route.status } : void 0
846
+ );
847
+ });
848
+ }
849
+
850
+ // src/plugin/chain-hooks.ts
851
+ function chainHooks(...hooks) {
852
+ return async (context) => {
853
+ for (const hook of hooks) {
854
+ if (hook) await hook(context);
855
+ }
856
+ };
857
+ }
858
+
859
+ // src/plugin/hook-registry.ts
860
+ var FEATURE_HOOK_KEYS = {
861
+ upload: ["presignGuard", "onPresigned", "confirmGuard", "onConfirmed"],
862
+ download: ["presignGuard", "onPresigned"],
863
+ delete: ["guard", "onDeleted"],
864
+ multipart: [
865
+ "initGuard",
866
+ "partGuard",
867
+ "completeGuard",
868
+ "abortGuard",
869
+ "listGuard",
870
+ "onInit",
871
+ "onComplete",
872
+ "onAbort",
873
+ "onList"
874
+ ]
875
+ };
876
+
877
+ // src/plugin/types.ts
878
+ var RESERVED_PLUGIN_IDS = [
879
+ "handler",
880
+ "api",
881
+ "context",
882
+ "getPlugin"
883
+ ];
884
+
885
+ // src/plugin/apply-plugins.ts
886
+ function mergeHookField(pluginHooks, userHook) {
887
+ const present = [...pluginHooks, userHook].filter(
888
+ (h) => h != null
889
+ );
890
+ if (present.length === 0) return void 0;
891
+ if (present.length === 1) return present[0];
892
+ return chainHooks(...present);
893
+ }
894
+ function mergeFeatureHooks(feature, keys, plugins, userFeature) {
895
+ if (!userFeature && plugins.every((p) => !p.hooks?.[feature])) {
896
+ return userFeature;
897
+ }
898
+ const merged = { ...userFeature };
899
+ for (const key of keys) {
900
+ const fromPlugins = plugins.map(
901
+ (p) => p.hooks?.[feature]?.[key]
902
+ );
903
+ const userHook = userFeature?.[key];
904
+ const next = mergeHookField(fromPlugins, userHook);
905
+ if (next !== void 0) {
906
+ merged[key] = next;
907
+ }
908
+ }
909
+ return merged;
910
+ }
911
+ function applyPlugins(config) {
912
+ const plugins = config.plugins ?? [];
913
+ const seen = /* @__PURE__ */ new Set();
914
+ const byId = /* @__PURE__ */ new Map();
915
+ const context = {};
916
+ const getPlugin = (id) => byId.get(id);
917
+ for (const plugin of plugins) {
918
+ if (RESERVED_PLUGIN_IDS.includes(plugin.id)) {
919
+ throw new Error(
920
+ `Plugin id "${plugin.id}" is reserved on the dimahS3 instance. Choose a different id.`
921
+ );
922
+ }
923
+ if (seen.has(plugin.id)) {
924
+ throw new Error(
925
+ `Duplicate dimah-s3 plugin id "${plugin.id}". Each plugin id must be unique.`
926
+ );
927
+ }
928
+ seen.add(plugin.id);
929
+ byId.set(plugin.id, plugin);
930
+ }
931
+ for (const plugin of plugins) {
932
+ for (const dep of plugin.dependsOn ?? []) {
933
+ if (!seen.has(dep)) {
934
+ throw new Error(
935
+ `Plugin "${plugin.id}" depends on "${dep}", which is not registered.`
936
+ );
937
+ }
938
+ }
939
+ }
940
+ const visiting = /* @__PURE__ */ new Set();
941
+ const visited = /* @__PURE__ */ new Set();
942
+ function visit(id, stack) {
943
+ if (visited.has(id)) return;
944
+ if (visiting.has(id)) {
945
+ throw new Error(
946
+ `Circular plugin dependency detected: ${[...stack, id].join(" -> ")}.`
947
+ );
948
+ }
949
+ visiting.add(id);
950
+ const plugin = byId.get(id);
951
+ for (const dep of plugin?.dependsOn ?? []) {
952
+ visit(dep, [...stack, id]);
953
+ }
954
+ visiting.delete(id);
955
+ visited.add(id);
956
+ }
957
+ for (const plugin of plugins) {
958
+ visit(plugin.id, []);
959
+ }
960
+ for (const plugin of plugins) {
961
+ if (plugin.context !== void 0) {
962
+ context[plugin.id] = plugin.context;
963
+ }
964
+ }
965
+ const reservedRoutePaths = new Set(Object.values(S3_API_ROUTES));
966
+ const seenEndpointKeys = /* @__PURE__ */ new Set();
967
+ const endpoints = [];
968
+ for (const plugin of plugins) {
969
+ for (const [name, endpoint] of Object.entries(plugin.endpoints ?? {})) {
970
+ const mounted = pluginEndpointPath(plugin.id, endpoint.path);
971
+ if (reservedRoutePaths.has(mounted)) {
972
+ throw new Error(
973
+ `Plugin "${plugin.id}" endpoint "${name}" collides with a core route.`
974
+ );
975
+ }
976
+ const routeKey = `${endpoint.method} ${mounted}`;
977
+ if (seenEndpointKeys.has(routeKey)) {
978
+ throw new Error(`Duplicate plugin endpoint ${routeKey}.`);
979
+ }
980
+ seenEndpointKeys.add(routeKey);
981
+ endpoints.push({ ...endpoint, pluginId: plugin.id });
982
+ }
983
+ }
984
+ const { plugins: _omit, ...rest } = config;
985
+ for (const plugin of plugins) {
986
+ plugin.init?.({ config: rest, getPlugin });
987
+ }
988
+ const guard = mergeHookField(
989
+ plugins.map((p) => p.hooks?.guard),
990
+ rest.guard
991
+ );
992
+ const merged = {
993
+ ...rest,
994
+ ...guard !== void 0 ? { guard } : {}
995
+ };
996
+ for (const feature of Object.keys(FEATURE_HOOK_KEYS)) {
997
+ merged[feature] = mergeFeatureHooks(
998
+ feature,
999
+ FEATURE_HOOK_KEYS[feature],
1000
+ plugins,
1001
+ rest[feature]
1002
+ );
1003
+ }
1004
+ return {
1005
+ config: merged,
1006
+ context,
1007
+ getPlugin,
1008
+ endpoints
1009
+ };
1010
+ }
1011
+
1012
+ // src/plugin/create-endpoint.ts
1013
+ function createEndpoint(path, options, handler) {
1014
+ return {
1015
+ path,
1016
+ method: options.method,
1017
+ status: options.status,
1018
+ handler
1019
+ };
1020
+ }
1021
+
1022
+ // src/plugin/define-plugin.ts
1023
+ function definePlugin(plugin) {
1024
+ return plugin;
1025
+ }
1026
+
1027
+ // src/dimah-s3.ts
1028
+ function dimahS3(config) {
1029
+ const {
1030
+ config: resolved,
1031
+ context,
1032
+ getPlugin,
1033
+ endpoints
1034
+ } = applyPlugins(config);
1035
+ const basePath = resolved.basePath ?? S3_API_BASE_PATH;
1036
+ return {
1037
+ handler: createHandler(resolved, basePath, endpoints),
1038
+ api: createServerApi(resolved),
1039
+ context,
1040
+ getPlugin,
1041
+ ...context
1042
+ };
1043
+ }
1044
+
1045
+ export { FEATURE_HOOK_KEYS, RESERVED_PLUGIN_IDS, applyPlugins, chainHooks, createEndpoint, definePlugin, dimahS3, errors, resolveObjectAcl };
1046
+ //# sourceMappingURL=index.js.map
1047
+ //# sourceMappingURL=index.js.map