@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,311 @@
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 GCSConfig {
7
+ bucket: string
8
+ projectId?: string
9
+ keyFilename?: string
10
+ credentials?: Record<string, any>
11
+ root?: string
12
+ url?: string
13
+ visibility?: 'public' | 'private'
14
+ }
15
+
16
+ /**
17
+ * Google Cloud Storage filesystem driver.
18
+ *
19
+ * Requires: bun add @google-cloud/storage
20
+ */
21
+ export class GCSDriver implements FilesystemDriver {
22
+ private _storage: any = null
23
+ private _bucket: any = null
24
+ private readonly bucketName: string
25
+ private readonly prefix: string
26
+ private readonly urlBase: string | undefined
27
+ private readonly defaultVisibility: 'public' | 'private'
28
+ private readonly _config: GCSConfig
29
+
30
+ constructor(config: GCSConfig) {
31
+ this._config = config
32
+ this.bucketName = config.bucket
33
+ this.prefix = config.root ? config.root.replace(/^\/+|\/+$/g, '') + '/' : ''
34
+ this.urlBase = config.url
35
+ this.defaultVisibility = config.visibility ?? 'private'
36
+ }
37
+
38
+ private async bucket(): Promise<any> {
39
+ if (!this._bucket) {
40
+ let Storage: any
41
+ try {
42
+ ;({ Storage } = await import('@google-cloud/storage'))
43
+ } catch {
44
+ throw new FilesystemError(
45
+ 'The @google-cloud/storage package is required for the GCS driver. Install it with: bun add @google-cloud/storage',
46
+ )
47
+ }
48
+ const opts: any = {}
49
+ if (this._config.projectId) opts.projectId = this._config.projectId
50
+ if (this._config.keyFilename) opts.keyFilename = this._config.keyFilename
51
+ if (this._config.credentials) opts.credentials = this._config.credentials
52
+ this._storage = new Storage(opts)
53
+ this._bucket = this._storage.bucket(this.bucketName)
54
+ }
55
+ return this._bucket
56
+ }
57
+
58
+ private async file(path: string): Promise<any> {
59
+ const b = await this.bucket()
60
+ return b.file(this.key(path))
61
+ }
62
+
63
+ private key(path: string): string {
64
+ return this.prefix + path.replace(/^\/+/, '')
65
+ }
66
+
67
+ private stripPrefix(key: string): string {
68
+ return this.prefix && key.startsWith(this.prefix)
69
+ ? key.slice(this.prefix.length)
70
+ : key
71
+ }
72
+
73
+ // ── Reads ─────────────────────────────────────────────────────────────────
74
+
75
+ async exists(path: string): Promise<boolean> {
76
+ const f = await this.file(path)
77
+ const [exists] = await f.exists()
78
+ return exists
79
+ }
80
+
81
+ async get(path: string): Promise<string | null> {
82
+ const f = await this.file(path)
83
+ try {
84
+ const [contents] = await f.download()
85
+ return contents.toString('utf-8')
86
+ } catch (e: any) {
87
+ if (e.code === 404) return null
88
+ throw new FilesystemError(`GCS get failed: ${e.message}`, { path })
89
+ }
90
+ }
91
+
92
+ async getBytes(path: string): Promise<Uint8Array | null> {
93
+ const f = await this.file(path)
94
+ try {
95
+ const [contents] = await f.download()
96
+ return new Uint8Array(contents)
97
+ } catch (e: any) {
98
+ if (e.code === 404) return null
99
+ throw new FilesystemError(`GCS getBytes failed: ${e.message}`, { path })
100
+ }
101
+ }
102
+
103
+ async stream(path: string): Promise<ReadableStream | null> {
104
+ if (!(await this.exists(path))) return null
105
+ const f = await this.file(path)
106
+ const nodeStream = f.createReadStream()
107
+ return new ReadableStream({
108
+ start(controller) {
109
+ nodeStream.on('data', (chunk: any) => controller.enqueue(new Uint8Array(chunk)))
110
+ nodeStream.on('end', () => controller.close())
111
+ nodeStream.on('error', (err: any) => controller.error(err))
112
+ },
113
+ })
114
+ }
115
+
116
+ // ── Writes ────────────────────────────────────────────────────────────────
117
+
118
+ async put(path: string, contents: string | Uint8Array, options?: PutOptions): Promise<void> {
119
+ const f = await this.file(path)
120
+ const buffer = typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : Buffer.from(contents)
121
+ const contentType = options?.mimeType ?? guessMimeType(path)
122
+ await f.save(buffer, {
123
+ ...(contentType ? { contentType } : {}),
124
+ resumable: false,
125
+ })
126
+ const visibility = options?.visibility ?? this.defaultVisibility
127
+ if (visibility === 'public') {
128
+ await f.makePublic()
129
+ } else {
130
+ await f.makePrivate()
131
+ }
132
+ }
133
+
134
+ async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
135
+ const body = new Uint8Array(await new Response(stream).arrayBuffer())
136
+ await this.put(path, body, options)
137
+ }
138
+
139
+ async append(path: string, contents: string): Promise<void> {
140
+ const existing = (await this.get(path)) ?? ''
141
+ await this.put(path, existing + contents)
142
+ }
143
+
144
+ async prepend(path: string, contents: string): Promise<void> {
145
+ const existing = (await this.get(path)) ?? ''
146
+ await this.put(path, contents + existing)
147
+ }
148
+
149
+ // ── Operations ────────────────────────────────────────────────────────────
150
+
151
+ async delete(path: string | string[]): Promise<boolean> {
152
+ const paths = Array.isArray(path) ? path : [path]
153
+ try {
154
+ for (const p of paths) {
155
+ const f = await this.file(p)
156
+ await f.delete({ ignoreNotFound: true })
157
+ }
158
+ return true
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ async copy(from: string, to: string): Promise<void> {
165
+ const src = await this.file(from)
166
+ const dest = await this.file(to)
167
+ await src.copy(dest)
168
+ }
169
+
170
+ async move(from: string, to: string): Promise<void> {
171
+ const src = await this.file(from)
172
+ const dest = await this.file(to)
173
+ await src.move(dest)
174
+ }
175
+
176
+ // ── Metadata ──────────────────────────────────────────────────────────────
177
+
178
+ async size(path: string): Promise<number> {
179
+ const meta = await this.metadata(path)
180
+ return parseInt(meta.size ?? '0', 10)
181
+ }
182
+
183
+ async lastModified(path: string): Promise<number> {
184
+ const meta = await this.metadata(path)
185
+ return meta.updated ? new Date(meta.updated).getTime() : 0
186
+ }
187
+
188
+ async mimeType(path: string): Promise<string | null> {
189
+ const meta = await this.metadata(path)
190
+ return meta.contentType ?? null
191
+ }
192
+
193
+ path(filePath: string): string {
194
+ return this.key(filePath)
195
+ }
196
+
197
+ // ── URLs ──────────────────────────────────────────────────────────────────
198
+
199
+ url(path: string): string {
200
+ const k = this.key(path)
201
+ if (this.urlBase) {
202
+ return `${this.urlBase.replace(/\/+$/, '')}/${k}`
203
+ }
204
+ return `https://storage.googleapis.com/${this.bucketName}/${k}`
205
+ }
206
+
207
+ async temporaryUrl(path: string, expiration: number, _options?: Record<string, any>): Promise<string> {
208
+ const f = await this.file(path)
209
+ const [url] = await f.getSignedUrl({
210
+ action: 'read',
211
+ expires: Date.now() + expiration * 1000,
212
+ })
213
+ return url
214
+ }
215
+
216
+ // ── Directories ───────────────────────────────────────────────────────────
217
+
218
+ async files(directory: string = ''): Promise<string[]> {
219
+ const prefix = this.directoryPrefix(directory)
220
+ const b = await this.bucket()
221
+ const [files] = await b.getFiles({ prefix, delimiter: '/' })
222
+ return files
223
+ .map((f: any) => this.stripPrefix(f.name))
224
+ .filter((name: string) => !name.endsWith('/'))
225
+ }
226
+
227
+ async allFiles(directory: string = ''): Promise<string[]> {
228
+ const prefix = this.directoryPrefix(directory)
229
+ const b = await this.bucket()
230
+ const [files] = await b.getFiles({ prefix })
231
+ return files
232
+ .map((f: any) => this.stripPrefix(f.name))
233
+ .filter((name: string) => !name.endsWith('/'))
234
+ .sort()
235
+ }
236
+
237
+ async directories(directory: string = ''): Promise<string[]> {
238
+ const prefix = this.directoryPrefix(directory)
239
+ const b = await this.bucket()
240
+ const [, , apiResponse] = await b.getFiles({ prefix, delimiter: '/', autoPaginate: false })
241
+ return (apiResponse.prefixes ?? []).map((p: string) =>
242
+ this.stripPrefix(p).replace(/\/$/, ''),
243
+ )
244
+ }
245
+
246
+ async allDirectories(directory: string = ''): Promise<string[]> {
247
+ const allFiles = await this.allFiles(directory)
248
+ const dirs = new Set<string>()
249
+ for (const file of allFiles) {
250
+ const parts = file.split('/')
251
+ for (let i = 1; i < parts.length; i++) {
252
+ dirs.add(parts.slice(0, i).join('/'))
253
+ }
254
+ }
255
+ return [...dirs].sort()
256
+ }
257
+
258
+ async makeDirectory(path: string): Promise<void> {
259
+ const f = await this.file(path + '/')
260
+ await f.save('', { resumable: false })
261
+ }
262
+
263
+ async deleteDirectory(directory: string): Promise<boolean> {
264
+ const prefix = this.directoryPrefix(directory)
265
+ const b = await this.bucket()
266
+ try {
267
+ await b.deleteFiles({ prefix, force: true })
268
+ return true
269
+ } catch {
270
+ return false
271
+ }
272
+ }
273
+
274
+ // ── Visibility ────────────────────────────────────────────────────────────
275
+
276
+ async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
277
+ const f = await this.file(path)
278
+ if (visibility === 'public') {
279
+ await f.makePublic()
280
+ } else {
281
+ await f.makePrivate()
282
+ }
283
+ }
284
+
285
+ async getVisibility(path: string): Promise<string> {
286
+ const f = await this.file(path)
287
+ try {
288
+ const [isPublic] = await f.isPublic()
289
+ return isPublic ? 'public' : 'private'
290
+ } catch {
291
+ return 'private'
292
+ }
293
+ }
294
+
295
+ // ── Internal ──────────────────────────────────────────────────────────────
296
+
297
+ private async metadata(path: string): Promise<any> {
298
+ const f = await this.file(path)
299
+ try {
300
+ const [meta] = await f.getMetadata()
301
+ return meta
302
+ } catch (e: any) {
303
+ if (e.code === 404) throw new FileNotFoundError(path)
304
+ throw new FilesystemError(`GCS metadata failed: ${e.message}`, { path })
305
+ }
306
+ }
307
+
308
+ private directoryPrefix(directory: string): string {
309
+ return directory ? this.key(directory).replace(/\/$/, '') + '/' : this.prefix
310
+ }
311
+ }
@@ -0,0 +1,281 @@
1
+ import { resolve, join, dirname, relative } from 'node:path'
2
+ import { mkdir, rm, readdir, stat, rename, copyFile, chmod, appendFile } from 'node:fs/promises'
3
+ import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
4
+ import { FilesystemError } from '../errors/FilesystemError.ts'
5
+ import { FileNotFoundError } from '../errors/FileNotFoundError.ts'
6
+
7
+ export class LocalDriver implements FilesystemDriver {
8
+ private readonly root: string
9
+ private readonly urlBase: string | undefined
10
+ private readonly defaultVisibility: 'public' | 'private'
11
+
12
+ constructor(root: string, urlBase?: string, defaultVisibility: 'public' | 'private' = 'public') {
13
+ this.root = resolve(root)
14
+ this.urlBase = urlBase
15
+ this.defaultVisibility = defaultVisibility
16
+ }
17
+
18
+ // ── Reads ───────────────────────────────────────────────────────────────────
19
+
20
+ async exists(path: string): Promise<boolean> {
21
+ return Bun.file(this.fullPath(path)).exists()
22
+ }
23
+
24
+ async get(path: string): Promise<string | null> {
25
+ const fp = this.fullPath(path)
26
+ const file = Bun.file(fp)
27
+ if (!(await file.exists())) return null
28
+ return file.text()
29
+ }
30
+
31
+ async getBytes(path: string): Promise<Uint8Array | null> {
32
+ const fp = this.fullPath(path)
33
+ const file = Bun.file(fp)
34
+ if (!(await file.exists())) return null
35
+ return new Uint8Array(await file.arrayBuffer())
36
+ }
37
+
38
+ async stream(path: string): Promise<ReadableStream | null> {
39
+ const fp = this.fullPath(path)
40
+ const file = Bun.file(fp)
41
+ if (!(await file.exists())) return null
42
+ return file.stream()
43
+ }
44
+
45
+ // ── Writes ──────────────────────────────────────────────────────────────────
46
+
47
+ async put(path: string, contents: string | Uint8Array, options?: PutOptions): Promise<void> {
48
+ const fp = this.fullPath(path)
49
+ await mkdir(dirname(fp), { recursive: true })
50
+ await Bun.write(fp, contents)
51
+
52
+ const visibility = options?.visibility ?? this.defaultVisibility
53
+ await this.applyVisibility(fp, visibility)
54
+ }
55
+
56
+ async putStream(path: string, stream: ReadableStream, options?: PutOptions): Promise<void> {
57
+ const fp = this.fullPath(path)
58
+ await mkdir(dirname(fp), { recursive: true })
59
+ // Consume the stream into a Response to get the full body, then write
60
+ const body = await new Response(stream).arrayBuffer()
61
+ await Bun.write(fp, body)
62
+
63
+ const visibility = options?.visibility ?? this.defaultVisibility
64
+ await this.applyVisibility(fp, visibility)
65
+ }
66
+
67
+ async append(path: string, contents: string): Promise<void> {
68
+ const fp = this.fullPath(path)
69
+ await mkdir(dirname(fp), { recursive: true })
70
+ await appendFile(fp, contents)
71
+ }
72
+
73
+ async prepend(path: string, contents: string): Promise<void> {
74
+ const fp = this.fullPath(path)
75
+ await mkdir(dirname(fp), { recursive: true })
76
+ const file = Bun.file(fp)
77
+ const existing = (await file.exists()) ? await file.text() : ''
78
+ await Bun.write(fp, contents + existing)
79
+ }
80
+
81
+ // ── Operations ──────────────────────────────────────────────────────────────
82
+
83
+ async delete(path: string | string[]): Promise<boolean> {
84
+ const paths = Array.isArray(path) ? path : [path]
85
+ let allDeleted = true
86
+
87
+ for (const p of paths) {
88
+ const fp = this.fullPath(p)
89
+ try {
90
+ if (await Bun.file(fp).exists()) {
91
+ await rm(fp)
92
+ } else {
93
+ allDeleted = false
94
+ }
95
+ } catch {
96
+ allDeleted = false
97
+ }
98
+ }
99
+
100
+ return allDeleted
101
+ }
102
+
103
+ async copy(from: string, to: string): Promise<void> {
104
+ const fromPath = this.fullPath(from)
105
+ const toPath = this.fullPath(to)
106
+ await mkdir(dirname(toPath), { recursive: true })
107
+ await copyFile(fromPath, toPath)
108
+ }
109
+
110
+ async move(from: string, to: string): Promise<void> {
111
+ const fromPath = this.fullPath(from)
112
+ const toPath = this.fullPath(to)
113
+ await mkdir(dirname(toPath), { recursive: true })
114
+ await rename(fromPath, toPath)
115
+ }
116
+
117
+ // ── Metadata ────────────────────────────────────────────────────────────────
118
+
119
+ async size(path: string): Promise<number> {
120
+ const fp = this.fullPath(path)
121
+ try {
122
+ const s = await stat(fp)
123
+ return s.size
124
+ } catch {
125
+ throw new FileNotFoundError(path)
126
+ }
127
+ }
128
+
129
+ async lastModified(path: string): Promise<number> {
130
+ const fp = this.fullPath(path)
131
+ try {
132
+ const s = await stat(fp)
133
+ return Math.floor(s.mtimeMs)
134
+ } catch {
135
+ throw new FileNotFoundError(path)
136
+ }
137
+ }
138
+
139
+ async mimeType(path: string): Promise<string | null> {
140
+ const fp = this.fullPath(path)
141
+ const file = Bun.file(fp)
142
+ if (!(await file.exists())) return null
143
+ return file.type || null
144
+ }
145
+
146
+ path(filePath: string): string {
147
+ return this.fullPath(filePath)
148
+ }
149
+
150
+ // ── URLs ────────────────────────────────────────────────────────────────────
151
+
152
+ url(path: string): string {
153
+ if (!this.urlBase) {
154
+ throw new FilesystemError('URL generation is not supported — no url configured for this disk.', { path })
155
+ }
156
+ return `${this.urlBase.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
157
+ }
158
+
159
+ async temporaryUrl(_path: string, _expiration: number, _options?: Record<string, any>): Promise<string> {
160
+ throw new FilesystemError('Temporary URLs are not supported by the local driver.')
161
+ }
162
+
163
+ // ── Directories ─────────────────────────────────────────────────────────────
164
+
165
+ async files(directory: string = ''): Promise<string[]> {
166
+ const dir = this.fullPath(directory)
167
+ try {
168
+ const entries = await readdir(dir, { withFileTypes: true })
169
+ return entries
170
+ .filter((e) => e.isFile())
171
+ .map((e) => directory ? `${directory}/${e.name}` : e.name)
172
+ } catch {
173
+ return []
174
+ }
175
+ }
176
+
177
+ async allFiles(directory: string = ''): Promise<string[]> {
178
+ const dir = this.fullPath(directory)
179
+ try {
180
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true })
181
+ const results: string[] = []
182
+ for (const e of entries) {
183
+ if (e.isFile()) {
184
+ // Build relative path from the parentPath if available
185
+ const parentRel = this.entryRelativePath(e, dir)
186
+ const full = parentRel ? `${parentRel}/${e.name}` : e.name
187
+ results.push(directory ? `${directory}/${full}` : full)
188
+ }
189
+ }
190
+ return results.sort()
191
+ } catch {
192
+ return []
193
+ }
194
+ }
195
+
196
+ async directories(directory: string = ''): Promise<string[]> {
197
+ const dir = this.fullPath(directory)
198
+ try {
199
+ const entries = await readdir(dir, { withFileTypes: true })
200
+ return entries
201
+ .filter((e) => e.isDirectory())
202
+ .map((e) => directory ? `${directory}/${e.name}` : e.name)
203
+ } catch {
204
+ return []
205
+ }
206
+ }
207
+
208
+ async allDirectories(directory: string = ''): Promise<string[]> {
209
+ const dir = this.fullPath(directory)
210
+ try {
211
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true })
212
+ const results: string[] = []
213
+ for (const e of entries) {
214
+ if (e.isDirectory()) {
215
+ const parentRel = this.entryRelativePath(e, dir)
216
+ const full = parentRel ? `${parentRel}/${e.name}` : e.name
217
+ results.push(directory ? `${directory}/${full}` : full)
218
+ }
219
+ }
220
+ return results.sort()
221
+ } catch {
222
+ return []
223
+ }
224
+ }
225
+
226
+ async makeDirectory(path: string): Promise<void> {
227
+ await mkdir(this.fullPath(path), { recursive: true })
228
+ }
229
+
230
+ async deleteDirectory(directory: string): Promise<boolean> {
231
+ const fp = this.fullPath(directory)
232
+ try {
233
+ await rm(fp, { recursive: true, force: true })
234
+ return true
235
+ } catch {
236
+ return false
237
+ }
238
+ }
239
+
240
+ // ── Visibility ──────────────────────────────────────────────────────────────
241
+
242
+ async setVisibility(path: string, visibility: 'public' | 'private'): Promise<void> {
243
+ await this.applyVisibility(this.fullPath(path), visibility)
244
+ }
245
+
246
+ async getVisibility(path: string): Promise<string> {
247
+ const fp = this.fullPath(path)
248
+ const s = await stat(fp)
249
+ // Check if "others" have read permission (o+r)
250
+ return (s.mode & 0o004) ? 'public' : 'private'
251
+ }
252
+
253
+ // ── Internal ────────────────────────────────────────────────────────────────
254
+
255
+ private fullPath(path: string): string {
256
+ const resolved = resolve(this.root, path)
257
+ this.assertWithinRoot(resolved)
258
+ return resolved
259
+ }
260
+
261
+ private assertWithinRoot(resolvedPath: string): void {
262
+ if (!resolvedPath.startsWith(this.root)) {
263
+ throw new FilesystemError(
264
+ 'Path traversal detected — resolved path escapes the disk root.',
265
+ { resolved: resolvedPath, root: this.root },
266
+ )
267
+ }
268
+ }
269
+
270
+ private async applyVisibility(fullPath: string, visibility: 'public' | 'private'): Promise<void> {
271
+ const mode = visibility === 'public' ? 0o644 : 0o600
272
+ await chmod(fullPath, mode)
273
+ }
274
+
275
+ private entryRelativePath(entry: any, baseDir: string): string {
276
+ // Bun/Node 20+ Dirent has parentPath or path property
277
+ const parent: string | undefined = entry.parentPath ?? entry.path
278
+ if (!parent) return ''
279
+ return relative(baseDir, parent)
280
+ }
281
+ }
@@ -0,0 +1,35 @@
1
+ import type { FilesystemDriver, PutOptions } from '../contracts/FilesystemDriver.ts'
2
+
3
+ export class NullDriver implements FilesystemDriver {
4
+ async exists(_path: string): Promise<boolean> { return false }
5
+ async get(_path: string): Promise<string | null> { return null }
6
+ async getBytes(_path: string): Promise<Uint8Array | null> { return null }
7
+ async stream(_path: string): Promise<ReadableStream | null> { return null }
8
+
9
+ async put(_path: string, _contents: string | Uint8Array, _options?: PutOptions): Promise<void> {}
10
+ async putStream(_path: string, _stream: ReadableStream, _options?: PutOptions): Promise<void> {}
11
+ async append(_path: string, _contents: string): Promise<void> {}
12
+ async prepend(_path: string, _contents: string): Promise<void> {}
13
+
14
+ async delete(_path: string | string[]): Promise<boolean> { return true }
15
+ async copy(_from: string, _to: string): Promise<void> {}
16
+ async move(_from: string, _to: string): Promise<void> {}
17
+
18
+ async size(_path: string): Promise<number> { return 0 }
19
+ async lastModified(_path: string): Promise<number> { return 0 }
20
+ async mimeType(_path: string): Promise<string | null> { return null }
21
+ path(filePath: string): string { return filePath }
22
+
23
+ url(path: string): string { return path }
24
+ async temporaryUrl(path: string, _expiration: number): Promise<string> { return path }
25
+
26
+ async files(_directory?: string): Promise<string[]> { return [] }
27
+ async allFiles(_directory?: string): Promise<string[]> { return [] }
28
+ async directories(_directory?: string): Promise<string[]> { return [] }
29
+ async allDirectories(_directory?: string): Promise<string[]> { return [] }
30
+ async makeDirectory(_path: string): Promise<void> {}
31
+ async deleteDirectory(_directory: string): Promise<boolean> { return true }
32
+
33
+ async setVisibility(_path: string, _visibility: 'public' | 'private'): Promise<void> {}
34
+ async getVisibility(_path: string): Promise<string> { return 'public' }
35
+ }