@mantiq/filesystem 0.0.1
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 +19 -0
- package/package.json +83 -0
- package/src/FilesystemManager.ts +177 -0
- package/src/FilesystemServiceProvider.ts +30 -0
- package/src/contracts/FilesystemConfig.ts +60 -0
- package/src/contracts/FilesystemDriver.ts +45 -0
- package/src/drivers/AzureBlobDriver.ts +377 -0
- package/src/drivers/FTPDriver.ts +299 -0
- package/src/drivers/GCSDriver.ts +311 -0
- package/src/drivers/LocalDriver.ts +281 -0
- package/src/drivers/NullDriver.ts +35 -0
- package/src/drivers/S3Driver.ts +449 -0
- package/src/drivers/SFTPDriver.ts +306 -0
- package/src/errors/FileExistsError.ts +7 -0
- package/src/errors/FileNotFoundError.ts +7 -0
- package/src/errors/FilesystemError.ts +7 -0
- package/src/helpers/mime.ts +61 -0
- package/src/helpers/storage.ts +13 -0
- package/src/index.ts +38 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
|
|
2
|
+
import { FilesystemError } from '../errors/FilesystemError.ts'
|
|
3
|
+
import { FileNotFoundError } from '../errors/FileNotFoundError.ts'
|
|
4
|
+
import { guessMimeType } from '../helpers/mime.ts'
|
|
5
|
+
|
|
6
|
+
export interface S3Config {
|
|
7
|
+
bucket: string
|
|
8
|
+
region?: string
|
|
9
|
+
key?: string
|
|
10
|
+
secret?: string
|
|
11
|
+
token?: string
|
|
12
|
+
endpoint?: string
|
|
13
|
+
forcePathStyle?: boolean
|
|
14
|
+
root?: string
|
|
15
|
+
url?: string
|
|
16
|
+
visibility?: 'public' | 'private'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* S3-compatible filesystem driver.
|
|
21
|
+
*
|
|
22
|
+
* Works with AWS S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2,
|
|
23
|
+
* MinIO, and any other S3-compatible object storage.
|
|
24
|
+
*
|
|
25
|
+
* Requires: bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
26
|
+
*/
|
|
27
|
+
export class S3Driver implements FilesystemDriver {
|
|
28
|
+
private _client: any = null
|
|
29
|
+
private readonly bucket: string
|
|
30
|
+
private readonly prefix: string
|
|
31
|
+
private readonly urlBase: string | undefined
|
|
32
|
+
private readonly defaultVisibility: 'public' | 'private'
|
|
33
|
+
private readonly _config: S3Config
|
|
34
|
+
|
|
35
|
+
constructor(config: S3Config) {
|
|
36
|
+
this._config = config
|
|
37
|
+
this.bucket = config.bucket
|
|
38
|
+
this.prefix = config.root ? config.root.replace(/^\/+|\/+$/g, '') + '/' : ''
|
|
39
|
+
this.urlBase = config.url
|
|
40
|
+
this.defaultVisibility = config.visibility ?? 'private'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── SDK lazy loading ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
private async client(): Promise<any> {
|
|
46
|
+
if (!this._client) {
|
|
47
|
+
let S3Client: any
|
|
48
|
+
try {
|
|
49
|
+
;({ S3Client } = await import('@aws-sdk/client-s3'))
|
|
50
|
+
} catch {
|
|
51
|
+
throw new FilesystemError(
|
|
52
|
+
'The @aws-sdk/client-s3 package is required for the S3 driver. Install it with: bun add @aws-sdk/client-s3',
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const opts: any = { region: this._config.region ?? 'us-east-1' }
|
|
56
|
+
if (this._config.key && this._config.secret) {
|
|
57
|
+
opts.credentials = {
|
|
58
|
+
accessKeyId: this._config.key,
|
|
59
|
+
secretAccessKey: this._config.secret,
|
|
60
|
+
...(this._config.token ? { sessionToken: this._config.token } : {}),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (this._config.endpoint) opts.endpoint = this._config.endpoint
|
|
64
|
+
if (this._config.forcePathStyle !== undefined) opts.forcePathStyle = this._config.forcePathStyle
|
|
65
|
+
this._client = new S3Client(opts)
|
|
66
|
+
}
|
|
67
|
+
return this._client
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async send(command: any): Promise<any> {
|
|
71
|
+
const client = await this.client()
|
|
72
|
+
return client.send(command)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private key(path: string): string {
|
|
76
|
+
return this.prefix + path.replace(/^\/+/, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private aclFor(visibility: 'public' | 'private'): string {
|
|
80
|
+
return visibility === 'public' ? 'public-read' : 'private'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Reads ─────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async exists(path: string): Promise<boolean> {
|
|
86
|
+
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
|
|
87
|
+
try {
|
|
88
|
+
await this.send(new HeadObjectCommand({ Bucket: this.bucket, Key: this.key(path) }))
|
|
89
|
+
return true
|
|
90
|
+
} catch (e: any) {
|
|
91
|
+
if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) return false
|
|
92
|
+
throw new FilesystemError(`S3 exists check failed: ${e.message}`, { path })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async get(path: string): Promise<string | null> {
|
|
97
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
|
98
|
+
try {
|
|
99
|
+
const res = await this.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.key(path) }))
|
|
100
|
+
return res.Body?.transformToString('utf-8') ?? null
|
|
101
|
+
} catch (e: any) {
|
|
102
|
+
if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) return null
|
|
103
|
+
throw new FilesystemError(`S3 get failed: ${e.message}`, { path })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getBytes(path: string): Promise<Uint8Array | null> {
|
|
108
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
|
109
|
+
try {
|
|
110
|
+
const res = await this.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.key(path) }))
|
|
111
|
+
return res.Body?.transformToByteArray() ?? null
|
|
112
|
+
} catch (e: any) {
|
|
113
|
+
if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) return null
|
|
114
|
+
throw new FilesystemError(`S3 getBytes failed: ${e.message}`, { path })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async stream(path: string): Promise<ReadableStream | null> {
|
|
119
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
|
|
120
|
+
try {
|
|
121
|
+
const res = await this.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.key(path) }))
|
|
122
|
+
const body = res.Body
|
|
123
|
+
if (!body) return null
|
|
124
|
+
// In Bun/modern Node, the body is already a web ReadableStream or can be converted
|
|
125
|
+
if (body instanceof ReadableStream) return body
|
|
126
|
+
if (typeof body.transformToWebStream === 'function') return body.transformToWebStream()
|
|
127
|
+
// Fallback: wrap Node readable
|
|
128
|
+
return new ReadableStream({
|
|
129
|
+
start(controller) {
|
|
130
|
+
body.on('data', (chunk: any) => controller.enqueue(new Uint8Array(chunk)))
|
|
131
|
+
body.on('end', () => controller.close())
|
|
132
|
+
body.on('error', (err: any) => controller.error(err))
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
} catch (e: any) {
|
|
136
|
+
if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) return null
|
|
137
|
+
throw new FilesystemError(`S3 stream failed: ${e.message}`, { path })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Writes ────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async put(path: string, contents: string | Uint8Array, options?: PutOptions): Promise<void> {
|
|
144
|
+
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
|
|
145
|
+
const body = typeof contents === 'string' ? new TextEncoder().encode(contents) : contents
|
|
146
|
+
const visibility = options?.visibility ?? this.defaultVisibility
|
|
147
|
+
await this.send(new PutObjectCommand({
|
|
148
|
+
Bucket: this.bucket,
|
|
149
|
+
Key: this.key(path),
|
|
150
|
+
Body: body,
|
|
151
|
+
ContentType: options?.mimeType ?? guessMimeType(path) ?? 'application/octet-stream',
|
|
152
|
+
ACL: this.aclFor(visibility),
|
|
153
|
+
}))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
|
|
157
|
+
// Consume stream to bytes and upload — for large files, users should use
|
|
158
|
+
// the AWS SDK's Upload (multipart) directly.
|
|
159
|
+
const body = new Uint8Array(await new Response(stream).arrayBuffer())
|
|
160
|
+
await this.put(path, body, options)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async append(path: string, contents: string): Promise<void> {
|
|
164
|
+
const existing = (await this.get(path)) ?? ''
|
|
165
|
+
await this.put(path, existing + contents)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async prepend(path: string, contents: string): Promise<void> {
|
|
169
|
+
const existing = (await this.get(path)) ?? ''
|
|
170
|
+
await this.put(path, contents + existing)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Operations ────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async delete(path: string | string[]): Promise<boolean> {
|
|
176
|
+
const paths = Array.isArray(path) ? path : [path]
|
|
177
|
+
if (paths.length === 0) return true
|
|
178
|
+
|
|
179
|
+
if (paths.length === 1) {
|
|
180
|
+
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3')
|
|
181
|
+
try {
|
|
182
|
+
await this.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: this.key(paths[0]!) }))
|
|
183
|
+
return true
|
|
184
|
+
} catch {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { DeleteObjectsCommand } = await import('@aws-sdk/client-s3')
|
|
190
|
+
try {
|
|
191
|
+
const result = await this.send(new DeleteObjectsCommand({
|
|
192
|
+
Bucket: this.bucket,
|
|
193
|
+
Delete: { Objects: paths.map((p) => ({ Key: this.key(p) })) },
|
|
194
|
+
}))
|
|
195
|
+
return !result.Errors?.length
|
|
196
|
+
} catch {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async copy(from: string, to: string): Promise<void> {
|
|
202
|
+
const { CopyObjectCommand } = await import('@aws-sdk/client-s3')
|
|
203
|
+
await this.send(new CopyObjectCommand({
|
|
204
|
+
Bucket: this.bucket,
|
|
205
|
+
Key: this.key(to),
|
|
206
|
+
CopySource: `${this.bucket}/${this.key(from)}`,
|
|
207
|
+
}))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async move(from: string, to: string): Promise<void> {
|
|
211
|
+
await this.copy(from, to)
|
|
212
|
+
await this.delete(from)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Metadata ──────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async size(path: string): Promise<number> {
|
|
218
|
+
const head = await this.headObject(path)
|
|
219
|
+
return head.ContentLength ?? 0
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async lastModified(path: string): Promise<number> {
|
|
223
|
+
const head = await this.headObject(path)
|
|
224
|
+
return head.LastModified ? head.LastModified.getTime() : 0
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async mimeType(path: string): Promise<string | null> {
|
|
228
|
+
const head = await this.headObject(path)
|
|
229
|
+
return head.ContentType ?? null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
path(filePath: string): string {
|
|
233
|
+
return this.key(filePath)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── URLs ──────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
url(path: string): string {
|
|
239
|
+
const k = this.key(path)
|
|
240
|
+
if (this.urlBase) {
|
|
241
|
+
return `${this.urlBase.replace(/\/+$/, '')}/${k}`
|
|
242
|
+
}
|
|
243
|
+
if (this._config.endpoint) {
|
|
244
|
+
if (this._config.forcePathStyle) {
|
|
245
|
+
return `${this._config.endpoint}/${this.bucket}/${k}`
|
|
246
|
+
}
|
|
247
|
+
return `${this._config.endpoint}/${k}`
|
|
248
|
+
}
|
|
249
|
+
const region = this._config.region ?? 'us-east-1'
|
|
250
|
+
return `https://${this.bucket}.s3.${region}.amazonaws.com/${k}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async temporaryUrl(path: string, expiration: number, _options?: Record<string, any>): Promise<string> {
|
|
254
|
+
let getSignedUrl: any
|
|
255
|
+
let GetObjectCommand: any
|
|
256
|
+
try {
|
|
257
|
+
;({ getSignedUrl } = await import('@aws-sdk/s3-request-presigner'))
|
|
258
|
+
;({ GetObjectCommand } = await import('@aws-sdk/client-s3'))
|
|
259
|
+
} catch {
|
|
260
|
+
throw new FilesystemError(
|
|
261
|
+
'The @aws-sdk/s3-request-presigner package is required for temporaryUrl(). Install it with: bun add @aws-sdk/s3-request-presigner',
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
const client = await this.client()
|
|
265
|
+
const command = new GetObjectCommand({ Bucket: this.bucket, Key: this.key(path) })
|
|
266
|
+
return getSignedUrl(client, command, { expiresIn: expiration })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Directories ───────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
async files(directory: string = ''): Promise<string[]> {
|
|
272
|
+
const prefix = this.directoryPrefix(directory)
|
|
273
|
+
const results: string[] = []
|
|
274
|
+
let token: string | undefined
|
|
275
|
+
|
|
276
|
+
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
|
|
277
|
+
do {
|
|
278
|
+
const res = await this.send(new ListObjectsV2Command({
|
|
279
|
+
Bucket: this.bucket,
|
|
280
|
+
Prefix: prefix,
|
|
281
|
+
Delimiter: '/',
|
|
282
|
+
ContinuationToken: token,
|
|
283
|
+
}))
|
|
284
|
+
for (const obj of res.Contents ?? []) {
|
|
285
|
+
if (obj.Key && obj.Key !== prefix) {
|
|
286
|
+
results.push(this.stripPrefix(obj.Key))
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
token = res.IsTruncated ? res.NextContinuationToken : undefined
|
|
290
|
+
} while (token)
|
|
291
|
+
|
|
292
|
+
return results
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async allFiles(directory: string = ''): Promise<string[]> {
|
|
296
|
+
const prefix = this.directoryPrefix(directory)
|
|
297
|
+
const results: string[] = []
|
|
298
|
+
let token: string | undefined
|
|
299
|
+
|
|
300
|
+
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
|
|
301
|
+
do {
|
|
302
|
+
const res = await this.send(new ListObjectsV2Command({
|
|
303
|
+
Bucket: this.bucket,
|
|
304
|
+
Prefix: prefix,
|
|
305
|
+
ContinuationToken: token,
|
|
306
|
+
}))
|
|
307
|
+
for (const obj of res.Contents ?? []) {
|
|
308
|
+
if (obj.Key && !obj.Key.endsWith('/')) {
|
|
309
|
+
results.push(this.stripPrefix(obj.Key))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
token = res.IsTruncated ? res.NextContinuationToken : undefined
|
|
313
|
+
} while (token)
|
|
314
|
+
|
|
315
|
+
return results.sort()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async directories(directory: string = ''): Promise<string[]> {
|
|
319
|
+
const prefix = this.directoryPrefix(directory)
|
|
320
|
+
const results: string[] = []
|
|
321
|
+
let token: string | undefined
|
|
322
|
+
|
|
323
|
+
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3')
|
|
324
|
+
do {
|
|
325
|
+
const res = await this.send(new ListObjectsV2Command({
|
|
326
|
+
Bucket: this.bucket,
|
|
327
|
+
Prefix: prefix,
|
|
328
|
+
Delimiter: '/',
|
|
329
|
+
ContinuationToken: token,
|
|
330
|
+
}))
|
|
331
|
+
for (const cp of res.CommonPrefixes ?? []) {
|
|
332
|
+
if (cp.Prefix) {
|
|
333
|
+
results.push(this.stripPrefix(cp.Prefix).replace(/\/$/, ''))
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
token = res.IsTruncated ? res.NextContinuationToken : undefined
|
|
337
|
+
} while (token)
|
|
338
|
+
|
|
339
|
+
return results
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async allDirectories(directory: string = ''): Promise<string[]> {
|
|
343
|
+
const allFiles = await this.allFiles(directory)
|
|
344
|
+
const dirs = new Set<string>()
|
|
345
|
+
for (const file of allFiles) {
|
|
346
|
+
const parts = file.split('/')
|
|
347
|
+
// Build all parent directory paths
|
|
348
|
+
for (let i = 1; i < parts.length; i++) {
|
|
349
|
+
dirs.add(parts.slice(0, i).join('/'))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return [...dirs].sort()
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async makeDirectory(path: string): Promise<void> {
|
|
356
|
+
const { PutObjectCommand } = await import('@aws-sdk/client-s3')
|
|
357
|
+
const dirKey = this.key(path).replace(/\/$/, '') + '/'
|
|
358
|
+
await this.send(new PutObjectCommand({
|
|
359
|
+
Bucket: this.bucket,
|
|
360
|
+
Key: dirKey,
|
|
361
|
+
Body: '',
|
|
362
|
+
ContentType: 'application/x-directory',
|
|
363
|
+
}))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async deleteDirectory(directory: string): Promise<boolean> {
|
|
367
|
+
const prefix = this.directoryPrefix(directory)
|
|
368
|
+
const { ListObjectsV2Command, DeleteObjectsCommand } = await import('@aws-sdk/client-s3')
|
|
369
|
+
|
|
370
|
+
let token: string | undefined
|
|
371
|
+
try {
|
|
372
|
+
do {
|
|
373
|
+
const res = await this.send(new ListObjectsV2Command({
|
|
374
|
+
Bucket: this.bucket,
|
|
375
|
+
Prefix: prefix,
|
|
376
|
+
ContinuationToken: token,
|
|
377
|
+
}))
|
|
378
|
+
const objects = (res.Contents ?? []).map((o: any) => ({ Key: o.Key }))
|
|
379
|
+
if (objects.length > 0) {
|
|
380
|
+
await this.send(new DeleteObjectsCommand({
|
|
381
|
+
Bucket: this.bucket,
|
|
382
|
+
Delete: { Objects: objects },
|
|
383
|
+
}))
|
|
384
|
+
}
|
|
385
|
+
token = res.IsTruncated ? res.NextContinuationToken : undefined
|
|
386
|
+
} while (token)
|
|
387
|
+
return true
|
|
388
|
+
} catch {
|
|
389
|
+
return false
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Visibility ────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
|
|
396
|
+
const { PutObjectAclCommand } = await import('@aws-sdk/client-s3')
|
|
397
|
+
try {
|
|
398
|
+
await this.send(new PutObjectAclCommand({
|
|
399
|
+
Bucket: this.bucket,
|
|
400
|
+
Key: this.key(path),
|
|
401
|
+
ACL: this.aclFor(visibility),
|
|
402
|
+
}))
|
|
403
|
+
} catch (e: any) {
|
|
404
|
+
// R2 and some S3-compatible services don't support ACLs
|
|
405
|
+
throw new FilesystemError(
|
|
406
|
+
`Failed to set visibility. Your storage provider may not support ACLs: ${e.message}`,
|
|
407
|
+
{ path, visibility },
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async getVisibility(path: string): Promise<string> {
|
|
413
|
+
const { GetObjectAclCommand } = await import('@aws-sdk/client-s3')
|
|
414
|
+
try {
|
|
415
|
+
const res = await this.send(new GetObjectAclCommand({ Bucket: this.bucket, Key: this.key(path) }))
|
|
416
|
+
const isPublic = (res.Grants ?? []).some(
|
|
417
|
+
(g: any) => g.Grantee?.URI === 'http://acs.amazonaws.com/groups/global/AllUsers' && g.Permission === 'READ',
|
|
418
|
+
)
|
|
419
|
+
return isPublic ? 'public' : 'private'
|
|
420
|
+
} catch {
|
|
421
|
+
return 'private'
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
private async headObject(path: string): Promise<any> {
|
|
428
|
+
const { HeadObjectCommand } = await import('@aws-sdk/client-s3')
|
|
429
|
+
try {
|
|
430
|
+
return await this.send(new HeadObjectCommand({ Bucket: this.bucket, Key: this.key(path) }))
|
|
431
|
+
} catch (e: any) {
|
|
432
|
+
if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) {
|
|
433
|
+
throw new FileNotFoundError(path)
|
|
434
|
+
}
|
|
435
|
+
throw new FilesystemError(`S3 head failed: ${e.message}`, { path })
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private directoryPrefix(directory: string): string {
|
|
440
|
+
const base = directory ? this.key(directory).replace(/\/$/, '') + '/' : this.prefix
|
|
441
|
+
return base
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private stripPrefix(key: string): string {
|
|
445
|
+
return this.prefix && key.startsWith(this.prefix)
|
|
446
|
+
? key.slice(this.prefix.length)
|
|
447
|
+
: key
|
|
448
|
+
}
|
|
449
|
+
}
|