@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.
@@ -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
+ }