@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.
- package/README.md +76 -0
- package/esm/define-s3-blob-store.d.ts +36 -0
- package/esm/define-s3-blob-store.d.ts.map +1 -0
- package/esm/define-s3-blob-store.js +41 -0
- package/esm/define-s3-blob-store.js.map +1 -0
- package/esm/define-s3-blob-store.spec.d.ts +2 -0
- package/esm/define-s3-blob-store.spec.d.ts.map +1 -0
- package/esm/define-s3-blob-store.spec.js +86 -0
- package/esm/define-s3-blob-store.spec.js.map +1 -0
- package/esm/ensure-bucket-lifecycle.d.ts +24 -0
- package/esm/ensure-bucket-lifecycle.d.ts.map +1 -0
- package/esm/ensure-bucket-lifecycle.js +37 -0
- package/esm/ensure-bucket-lifecycle.js.map +1 -0
- package/esm/index.d.ts +3 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +3 -0
- package/esm/index.js.map +1 -0
- package/esm/s3-blob-store.d.ts +82 -0
- package/esm/s3-blob-store.d.ts.map +1 -0
- package/esm/s3-blob-store.integration.spec.d.ts +2 -0
- package/esm/s3-blob-store.integration.spec.d.ts.map +1 -0
- package/esm/s3-blob-store.integration.spec.js +71 -0
- package/esm/s3-blob-store.integration.spec.js.map +1 -0
- package/esm/s3-blob-store.js +283 -0
- package/esm/s3-blob-store.js.map +1 -0
- package/esm/s3-blob-store.unit.spec.d.ts +2 -0
- package/esm/s3-blob-store.unit.spec.d.ts.map +1 -0
- package/esm/s3-blob-store.unit.spec.js +892 -0
- package/esm/s3-blob-store.unit.spec.js.map +1 -0
- package/esm/s3-stream-helpers.d.ts +35 -0
- package/esm/s3-stream-helpers.d.ts.map +1 -0
- package/esm/s3-stream-helpers.js +127 -0
- package/esm/s3-stream-helpers.js.map +1 -0
- package/package.json +55 -0
- package/src/define-s3-blob-store.spec.ts +24 -0
- package/src/define-s3-blob-store.ts +43 -0
- package/src/ensure-bucket-lifecycle.ts +47 -0
- package/src/index.ts +2 -0
- package/src/s3-blob-store.integration.spec.ts +78 -0
- package/src/s3-blob-store.ts +378 -0
- package/src/s3-blob-store.unit.spec.ts +539 -0
- 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
|
+
}
|