@furystack/s3-blob-store 1.0.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 (42) hide show
  1. package/README.md +76 -0
  2. package/esm/define-s3-blob-store.d.ts +36 -0
  3. package/esm/define-s3-blob-store.d.ts.map +1 -0
  4. package/esm/define-s3-blob-store.js +41 -0
  5. package/esm/define-s3-blob-store.js.map +1 -0
  6. package/esm/define-s3-blob-store.spec.d.ts +2 -0
  7. package/esm/define-s3-blob-store.spec.d.ts.map +1 -0
  8. package/esm/define-s3-blob-store.spec.js +86 -0
  9. package/esm/define-s3-blob-store.spec.js.map +1 -0
  10. package/esm/ensure-bucket-lifecycle.d.ts +24 -0
  11. package/esm/ensure-bucket-lifecycle.d.ts.map +1 -0
  12. package/esm/ensure-bucket-lifecycle.js +37 -0
  13. package/esm/ensure-bucket-lifecycle.js.map +1 -0
  14. package/esm/index.d.ts +3 -0
  15. package/esm/index.d.ts.map +1 -0
  16. package/esm/index.js +3 -0
  17. package/esm/index.js.map +1 -0
  18. package/esm/s3-blob-store.d.ts +82 -0
  19. package/esm/s3-blob-store.d.ts.map +1 -0
  20. package/esm/s3-blob-store.integration.spec.d.ts +2 -0
  21. package/esm/s3-blob-store.integration.spec.d.ts.map +1 -0
  22. package/esm/s3-blob-store.integration.spec.js +71 -0
  23. package/esm/s3-blob-store.integration.spec.js.map +1 -0
  24. package/esm/s3-blob-store.js +283 -0
  25. package/esm/s3-blob-store.js.map +1 -0
  26. package/esm/s3-blob-store.unit.spec.d.ts +2 -0
  27. package/esm/s3-blob-store.unit.spec.d.ts.map +1 -0
  28. package/esm/s3-blob-store.unit.spec.js +892 -0
  29. package/esm/s3-blob-store.unit.spec.js.map +1 -0
  30. package/esm/s3-stream-helpers.d.ts +35 -0
  31. package/esm/s3-stream-helpers.d.ts.map +1 -0
  32. package/esm/s3-stream-helpers.js +127 -0
  33. package/esm/s3-stream-helpers.js.map +1 -0
  34. package/package.json +55 -0
  35. package/src/define-s3-blob-store.spec.ts +24 -0
  36. package/src/define-s3-blob-store.ts +43 -0
  37. package/src/ensure-bucket-lifecycle.ts +47 -0
  38. package/src/index.ts +2 -0
  39. package/src/s3-blob-store.integration.spec.ts +78 -0
  40. package/src/s3-blob-store.ts +378 -0
  41. package/src/s3-blob-store.unit.spec.ts +539 -0
  42. package/src/s3-stream-helpers.ts +132 -0
@@ -0,0 +1,378 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ GetObjectCommand,
4
+ HeadObjectCommand,
5
+ ListObjectsV2Command,
6
+ PutObjectCommand,
7
+ type GetObjectCommandOutput,
8
+ type HeadObjectCommandOutput,
9
+ type ListObjectsV2CommandOutput,
10
+ type PutObjectCommandOutput,
11
+ type S3Client,
12
+ } from '@aws-sdk/client-s3'
13
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
14
+ import {
15
+ BlobStoreError,
16
+ validateBlobKey,
17
+ type BlobDownloadUrlOptions,
18
+ type BlobGetResult,
19
+ type BlobListOptions,
20
+ type BlobListResult,
21
+ type BlobMetadata,
22
+ type BlobPutInput,
23
+ type BlobPutOptions,
24
+ type BlobRef,
25
+ type BlobStore,
26
+ type BlobStoreCapabilities,
27
+ type BlobUploadUrl,
28
+ type BlobUploadUrlOptions,
29
+ } from '@furystack/blob-store'
30
+ import { DEFAULT_LIFECYCLE_RULE_ID, ensureBucketLifecycle } from './ensure-bucket-lifecycle.js'
31
+ import { bodyToBuffer, bodyToWebStream, stripQuotes, toUploadBody } from './s3-stream-helpers.js'
32
+
33
+ /**
34
+ * 5 TiB — the per-object limit AWS S3 enforces. Other S3-compatible
35
+ * backends (MinIO, R2) advertise smaller caps in practice; the value
36
+ * is a soft hint per
37
+ * {@link BlobStoreCapabilities.maxObjectBytes}.
38
+ */
39
+ export const S3_MAX_OBJECT_BYTES = 5_497_558_138_880
40
+
41
+ const CAPABILITIES: BlobStoreCapabilities = Object.freeze({
42
+ presignedUrls: true,
43
+ /**
44
+ * Single-part PUT only in v1. Apps that need resumable multipart
45
+ * uploads for very large payloads can compose `@aws-sdk/lib-storage`
46
+ * `Upload` themselves on the underlying `S3Client` and pass the
47
+ * resulting key back as a {@link BlobRef}. v1.x will lift this to
48
+ * `true` once the adapter exposes a multipart-aware `put` variant.
49
+ */
50
+ multipart: false,
51
+ range: true,
52
+ crossNodeAccessible: true,
53
+ /**
54
+ * Single-part PUT caps at 5 GiB on AWS S3. Larger payloads need
55
+ * multipart, which v1 does not expose — see the `multipart` note
56
+ * above.
57
+ */
58
+ maxObjectBytes: 5 * 1024 * 1024 * 1024,
59
+ })
60
+
61
+ /**
62
+ * Options accepted by {@link S3BlobStore}.
63
+ */
64
+ export type S3BlobStoreOptions = {
65
+ /**
66
+ * Caller-owned `S3Client` instance. The adapter never closes it;
67
+ * connection lifecycle stays with the application.
68
+ */
69
+ client: S3Client
70
+ /** Bucket every operation runs against. */
71
+ bucket: string
72
+ /**
73
+ * Optional key prefix prepended to every blob key on the wire. Useful
74
+ * for tenant scoping inside a shared bucket. Stored without the
75
+ * prefix in {@link BlobRef.key} so apps round-trip cleanly.
76
+ */
77
+ keyPrefix?: string
78
+ /**
79
+ * Adapter binding name. Defaults to `'s3'`. Set to a stable string
80
+ * when the same injector binds multiple S3 stores so {@link BlobRef}
81
+ * entries stay distinguishable.
82
+ */
83
+ name?: string
84
+ /**
85
+ * When true (default), installs a bucket lifecycle rule that aborts
86
+ * incomplete multipart uploads older than 24h on first put. Avoids
87
+ * orphan-multipart cost surprises. Disable for buckets the app
88
+ * lacks lifecycle permissions on.
89
+ */
90
+ manageLifecycle?: boolean
91
+ /**
92
+ * Override the rule id used by `manageLifecycle`. Defaults to
93
+ * `'furystack-blob-store-abort-incomplete-multipart'` so the rule is
94
+ * easy to inspect and remove out-of-band.
95
+ */
96
+ lifecycleRuleId?: string
97
+ /**
98
+ * Async callback invoked when the lifecycle setup attempt fails.
99
+ * Defaults to silent — apps wire this into their telemetry / logging
100
+ * sink to surface permission issues.
101
+ */
102
+ onLifecycleError?: (error: unknown) => void
103
+ }
104
+
105
+ const wrap = (
106
+ code: 'not-found' | 'io-error',
107
+ message: string,
108
+ key: string,
109
+ storeName: string,
110
+ cause: unknown,
111
+ ): never => {
112
+ throw new BlobStoreError(code, message, { key, storeName, cause })
113
+ }
114
+
115
+ const isNotFoundError = (cause: unknown): boolean => {
116
+ if (!cause || typeof cause !== 'object') return false
117
+ const { name } = cause as { name?: string }
118
+ const code = (cause as { Code?: string; code?: string }).Code ?? (cause as { code?: string }).code
119
+ const httpStatus = (cause as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode
120
+ return name === 'NoSuchKey' || code === 'NoSuchKey' || code === 'NotFound' || httpStatus === 404
121
+ }
122
+
123
+ /**
124
+ * S3-compatible {@link BlobStore} adapter. Targets AWS S3, MinIO,
125
+ * Cloudflare R2, Backblaze B2, and any other backend exposing the v3
126
+ * S3 API. The caller owns the underlying `S3Client` instance.
127
+ *
128
+ * Multipart uploads run automatically via `@aws-sdk/lib-storage`'s
129
+ * `Upload`; single-part PUT is used for known-small payloads when
130
+ * `contentLength` is supplied at put time.
131
+ */
132
+ export class S3BlobStore implements BlobStore {
133
+ public readonly capabilities: BlobStoreCapabilities = CAPABILITIES
134
+ public readonly storeName: string
135
+
136
+ readonly #client: S3Client
137
+ readonly #bucket: string
138
+ readonly #keyPrefix: string
139
+ readonly #manageLifecycle: boolean
140
+ readonly #lifecycleRuleId: string
141
+ readonly #onLifecycleError: ((error: unknown) => void) | undefined
142
+ #lifecycleAttempted = false
143
+ #disposed = false
144
+
145
+ constructor(options: S3BlobStoreOptions) {
146
+ if (!options.bucket || typeof options.bucket !== 'string') {
147
+ throw new BlobStoreError('invalid-config', 'S3BlobStore requires a non-empty `bucket`')
148
+ }
149
+ this.#client = options.client
150
+ this.#bucket = options.bucket
151
+ this.#keyPrefix = options.keyPrefix ?? ''
152
+ this.#manageLifecycle = options.manageLifecycle ?? true
153
+ this.#lifecycleRuleId = options.lifecycleRuleId ?? DEFAULT_LIFECYCLE_RULE_ID
154
+ this.#onLifecycleError = options.onLifecycleError
155
+ this.storeName = options.name ?? 's3'
156
+ }
157
+
158
+ public async put(key: string, payload: BlobPutInput, options: BlobPutOptions = {}): Promise<BlobRef> {
159
+ this.#ensureLive()
160
+ validateBlobKey(key)
161
+ await this.#ensureLifecycle()
162
+ const wireKey = this.#wireKey(key)
163
+ let etag: string | undefined
164
+ let contentLength: number | undefined
165
+ try {
166
+ const body = toUploadBody(payload)
167
+ const result: PutObjectCommandOutput = await this.#client.send(
168
+ new PutObjectCommand({
169
+ Bucket: this.#bucket,
170
+ Key: wireKey,
171
+ Body: body,
172
+ ContentType: options.contentType,
173
+ ContentLength: options.contentLength,
174
+ Metadata: options.metadata,
175
+ }),
176
+ )
177
+ etag = stripQuotes(result.ETag)
178
+ ;({ contentLength } = options)
179
+ } catch (cause) {
180
+ throw new BlobStoreError('io-error', `Failed to upload blob ${key}`, {
181
+ key,
182
+ storeName: this.storeName,
183
+ cause,
184
+ })
185
+ }
186
+ if (contentLength === undefined) {
187
+ try {
188
+ const head: HeadObjectCommandOutput = await this.#client.send(
189
+ new HeadObjectCommand({ Bucket: this.#bucket, Key: wireKey }),
190
+ )
191
+ contentLength = head.ContentLength
192
+ } catch {
193
+ // Best-effort: leave contentLength undefined when head is denied/missing.
194
+ }
195
+ }
196
+ return {
197
+ storeName: this.storeName,
198
+ key,
199
+ contentType: options.contentType,
200
+ contentLength,
201
+ etag,
202
+ }
203
+ }
204
+
205
+ public async get(key: string): Promise<BlobGetResult> {
206
+ this.#ensureLive()
207
+ validateBlobKey(key)
208
+ const wireKey = this.#wireKey(key)
209
+ let response: GetObjectCommandOutput
210
+ try {
211
+ response = await this.#client.send(new GetObjectCommand({ Bucket: this.#bucket, Key: wireKey }))
212
+ } catch (cause) {
213
+ if (isNotFoundError(cause)) {
214
+ wrap('not-found', `Blob not found: ${key}`, key, this.storeName, cause)
215
+ }
216
+ wrap('io-error', `Failed to read blob ${key}`, key, this.storeName, cause)
217
+ throw cause
218
+ }
219
+ const body = response.Body
220
+ if (!body) {
221
+ throw new BlobStoreError('io-error', `S3 returned no body for ${key}`, {
222
+ key,
223
+ storeName: this.storeName,
224
+ })
225
+ }
226
+ return {
227
+ stream: bodyToWebStream(body),
228
+ contentType: response.ContentType,
229
+ contentLength: response.ContentLength,
230
+ etag: stripQuotes(response.ETag),
231
+ }
232
+ }
233
+
234
+ public async head(key: string): Promise<BlobMetadata | undefined> {
235
+ this.#ensureLive()
236
+ validateBlobKey(key)
237
+ const wireKey = this.#wireKey(key)
238
+ let response: HeadObjectCommandOutput
239
+ try {
240
+ response = await this.#client.send(new HeadObjectCommand({ Bucket: this.#bucket, Key: wireKey }))
241
+ } catch (cause) {
242
+ if (isNotFoundError(cause)) return undefined
243
+ throw new BlobStoreError('io-error', `Failed to head blob ${key}`, {
244
+ key,
245
+ storeName: this.storeName,
246
+ cause,
247
+ })
248
+ }
249
+ return {
250
+ key,
251
+ contentType: response.ContentType,
252
+ contentLength: response.ContentLength ?? 0,
253
+ etag: stripQuotes(response.ETag),
254
+ lastModified: response.LastModified ?? new Date(0),
255
+ customMetadata: response.Metadata,
256
+ }
257
+ }
258
+
259
+ public async delete(key: string): Promise<void> {
260
+ this.#ensureLive()
261
+ validateBlobKey(key)
262
+ try {
263
+ await this.#client.send(new DeleteObjectCommand({ Bucket: this.#bucket, Key: this.#wireKey(key) }))
264
+ } catch (cause) {
265
+ if (isNotFoundError(cause)) return
266
+ throw new BlobStoreError('io-error', `Failed to delete blob ${key}`, {
267
+ key,
268
+ storeName: this.storeName,
269
+ cause,
270
+ })
271
+ }
272
+ }
273
+
274
+ public async list(prefix: string, options: BlobListOptions = {}): Promise<BlobListResult> {
275
+ this.#ensureLive()
276
+ if (typeof prefix !== 'string') {
277
+ throw new BlobStoreError('invalid-config', 'Prefix must be a string')
278
+ }
279
+ const limit = options.limit ?? 100
280
+ if (!Number.isInteger(limit) || limit <= 0) {
281
+ throw new BlobStoreError('invalid-config', `Limit must be a positive integer, got ${String(options.limit)}`)
282
+ }
283
+ let response: ListObjectsV2CommandOutput
284
+ try {
285
+ response = await this.#client.send(
286
+ new ListObjectsV2Command({
287
+ Bucket: this.#bucket,
288
+ Prefix: this.#keyPrefix + prefix,
289
+ MaxKeys: limit,
290
+ ContinuationToken: options.cursor,
291
+ }),
292
+ )
293
+ } catch (cause) {
294
+ throw new BlobStoreError('io-error', 'Failed to list blobs', { storeName: this.storeName, cause })
295
+ }
296
+ const items: BlobMetadata[] = (response.Contents ?? [])
297
+ .filter((entry): entry is { Key: string; Size?: number; LastModified?: Date; ETag?: string } => !!entry.Key)
298
+ .map((entry) => ({
299
+ key: this.#stripPrefix(entry.Key),
300
+ contentLength: entry.Size ?? 0,
301
+ etag: stripQuotes(entry.ETag),
302
+ lastModified: entry.LastModified ?? new Date(0),
303
+ }))
304
+ return {
305
+ items,
306
+ nextCursor: response.NextContinuationToken,
307
+ }
308
+ }
309
+
310
+ public async getDownloadUrl(key: string, options: BlobDownloadUrlOptions): Promise<string> {
311
+ this.#ensureLive()
312
+ validateBlobKey(key)
313
+ return getSignedUrl(this.#client, new GetObjectCommand({ Bucket: this.#bucket, Key: this.#wireKey(key) }), {
314
+ expiresIn: options.ttlSec,
315
+ })
316
+ }
317
+
318
+ public async getUploadUrl(key: string, options: BlobUploadUrlOptions): Promise<BlobUploadUrl> {
319
+ this.#ensureLive()
320
+ validateBlobKey(key)
321
+ // Note on `maxBytes`: a pre-signed PUT cannot enforce a server-side
322
+ // upload size cap — `ContentLength` would lock the upload to an exact
323
+ // size, not a maximum, so we deliberately omit it. Apps that need a
324
+ // hard cap should either use the filesystem adapter's server-proxy
325
+ // endpoint (which streams through a size enforcer) or layer a POST
326
+ // policy in front of S3. `options.maxBytes` is treated as advisory
327
+ // here so callers who pass it on a heterogeneous adapter set still
328
+ // see uniform behaviour.
329
+ const url = await getSignedUrl(
330
+ this.#client,
331
+ new PutObjectCommand({
332
+ Bucket: this.#bucket,
333
+ Key: this.#wireKey(key),
334
+ ContentType: options.contentType,
335
+ }),
336
+ { expiresIn: options.ttlSec },
337
+ )
338
+ return { url, method: 'PUT' }
339
+ }
340
+
341
+ public [Symbol.dispose](): void {
342
+ this.#disposed = true
343
+ }
344
+
345
+ /**
346
+ * Public helper for tests / advanced consumers: drains a `Body` from an
347
+ * S3 response into a single `Uint8Array`. Memory-bound — only safe
348
+ * when the caller knows the payload fits in RAM.
349
+ */
350
+ public static async bodyToBuffer(body: unknown): Promise<Uint8Array> {
351
+ return bodyToBuffer(body)
352
+ }
353
+
354
+ #wireKey(key: string): string {
355
+ return `${this.#keyPrefix}${key}`
356
+ }
357
+
358
+ #stripPrefix(wireKey: string): string {
359
+ return this.#keyPrefix && wireKey.startsWith(this.#keyPrefix) ? wireKey.slice(this.#keyPrefix.length) : wireKey
360
+ }
361
+
362
+ async #ensureLifecycle(): Promise<void> {
363
+ if (!this.#manageLifecycle || this.#lifecycleAttempted) return
364
+ this.#lifecycleAttempted = true
365
+ await ensureBucketLifecycle({
366
+ client: this.#client,
367
+ bucket: this.#bucket,
368
+ ruleId: this.#lifecycleRuleId,
369
+ onError: this.#onLifecycleError,
370
+ })
371
+ }
372
+
373
+ #ensureLive(): void {
374
+ if (this.#disposed) {
375
+ throw new BlobStoreError('io-error', 'S3BlobStore has been disposed', { storeName: this.storeName })
376
+ }
377
+ }
378
+ }