fs-object-storage 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.
@@ -0,0 +1,480 @@
1
+ // FsMinioClient.js - Main fs-compatible client for MinIO/S3 operations
2
+
3
+ import { Client as MinioClient } from 'minio';
4
+ import PathConverter from './PathConverter.js';
5
+ import StreamConverter from './StreamConverter.js';
6
+ import ErrorHandler from './ErrorHandler.js';
7
+
8
+ class FsMinioClient {
9
+ /**
10
+ * Create FsMinioClient instance
11
+ * @param {Object} options - Configuration options
12
+ * @param {string} options.endpoint - MinIO endpoint (e.g., 'localhost:9000')
13
+ * @param {string} options.accessKey - Access key
14
+ * @param {string} options.secretKey - Secret key
15
+ * @param {string} options.bucket - Bucket name
16
+ * @param {boolean} [options.useSSL=false] - Use SSL
17
+ * @param {string} [options.region='us-east-1'] - Region
18
+ * @param {string} [options.prefix=''] - Key prefix for all operations
19
+ */
20
+ constructor(options = {}) {
21
+ // Validate required options
22
+ if (!options.endpoint) throw new Error('endpoint is required');
23
+ if (!options.accessKey) throw new Error('accessKey is required');
24
+ if (!options.secretKey) throw new Error('secretKey is required');
25
+ if (!options.bucket) throw new Error('bucket is required');
26
+
27
+ // Initialize MinIO client
28
+ this.minioClient = new MinioClient({
29
+ endPoint: options.endpoint.split(':')[0],
30
+ port: parseInt(options.endpoint.split(':')[1]) || (options.useSSL ? 443 : 80),
31
+ useSSL: options.useSSL || false,
32
+ accessKey: options.accessKey,
33
+ secretKey: options.secretKey,
34
+ region: options.region || 'us-east-1'
35
+ });
36
+
37
+ // Initialize path converter
38
+ this.pathConverter = new PathConverter({
39
+ bucket: options.bucket,
40
+ prefix: options.prefix
41
+ });
42
+
43
+ this.bucket = options.bucket;
44
+ this._initialized = false;
45
+ }
46
+
47
+ /**
48
+ * Initialize client (create bucket if it doesn't exist)
49
+ * @returns {Promise<void>}
50
+ */
51
+ async initialize() {
52
+ if (this._initialized) return;
53
+
54
+ try {
55
+ const bucketExists = await this.minioClient.bucketExists(this.bucket);
56
+ if (!bucketExists) {
57
+ await this.minioClient.makeBucket(this.bucket);
58
+ }
59
+ this._initialized = true;
60
+ } catch (error) {
61
+ throw ErrorHandler.convertError(error, null, 'initialize');
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Read file content
67
+ * @param {string} filePath - File path
68
+ * @param {Object|string} [options] - Options or encoding string
69
+ * @param {string} [options.encoding] - Text encoding ('utf8', 'base64', etc.)
70
+ * @returns {Promise<Buffer|string>} File content
71
+ */
72
+ async readFile(filePath, options = {}) {
73
+ await this.initialize();
74
+
75
+ try {
76
+ // Handle options parameter
77
+ if (typeof options === 'string') {
78
+ options = { encoding: options };
79
+ }
80
+
81
+ this.pathConverter.validatePath(filePath);
82
+ const { bucket, key } = this.pathConverter.pathToMinIO(filePath);
83
+
84
+ // Get object stream
85
+ const stream = await this.minioClient.getObject(bucket, key);
86
+
87
+ // Convert stream to buffer
88
+ const buffer = await StreamConverter.streamToBuffer(stream);
89
+
90
+ // Return string if encoding specified, otherwise buffer
91
+ if (options.encoding) {
92
+ return buffer.toString(options.encoding);
93
+ }
94
+
95
+ return buffer;
96
+
97
+ } catch (error) {
98
+ throw ErrorHandler.convertError(error, filePath, 'open');
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Write file content
104
+ * @param {string} filePath - File path
105
+ * @param {string|Buffer|Uint8Array} data - Data to write
106
+ * @param {Object|string} [options] - Options or encoding string
107
+ * @param {string} [options.encoding='utf8'] - Text encoding for string data
108
+ * @returns {Promise<void>}
109
+ */
110
+ async writeFile(filePath, data, options = {}) {
111
+ await this.initialize();
112
+
113
+ try {
114
+ // Handle options parameter
115
+ if (typeof options === 'string') {
116
+ options = { encoding: options };
117
+ }
118
+
119
+ this.pathConverter.validatePath(filePath);
120
+ const { bucket, key } = this.pathConverter.pathToMinIO(filePath);
121
+
122
+ // Convert data to stream
123
+ const stream = StreamConverter.toReadableStream(data);
124
+ const size = StreamConverter.getDataSize(data);
125
+
126
+ // Upload object
127
+ if (size !== undefined) {
128
+ await this.minioClient.putObject(bucket, key, stream, size);
129
+ } else {
130
+ await this.minioClient.putObject(bucket, key, stream);
131
+ }
132
+
133
+ } catch (error) {
134
+ throw ErrorHandler.convertError(error, filePath, 'open');
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Check if file exists
140
+ * @param {string} filePath - File path
141
+ * @returns {Promise<boolean>} True if file exists
142
+ */
143
+ async exists(filePath) {
144
+ try {
145
+ await this.stat(filePath);
146
+ return true;
147
+ } catch (error) {
148
+ if (ErrorHandler.isNotFoundError(error)) {
149
+ return false;
150
+ }
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Get file statistics
157
+ * @param {string} filePath - File path
158
+ * @returns {Promise<Object>} File stats object
159
+ */
160
+ async stat(filePath) {
161
+ await this.initialize();
162
+
163
+ try {
164
+ this.pathConverter.validatePath(filePath);
165
+ const { bucket, key } = this.pathConverter.pathToMinIO(filePath);
166
+
167
+ // Get object stats
168
+ const objInfo = await this.minioClient.statObject(bucket, key);
169
+
170
+ // Convert to fs.Stats-like object
171
+ return {
172
+ isFile: () => true,
173
+ isDirectory: () => false,
174
+ isBlockDevice: () => false,
175
+ isCharacterDevice: () => false,
176
+ isSymbolicLink: () => false,
177
+ isFIFO: () => false,
178
+ isSocket: () => false,
179
+ size: objInfo.size,
180
+ mode: 0o644, // Default file permissions
181
+ uid: 0,
182
+ gid: 0,
183
+ atime: objInfo.lastModified,
184
+ mtime: objInfo.lastModified,
185
+ ctime: objInfo.lastModified,
186
+ birthtime: objInfo.lastModified,
187
+ dev: 0,
188
+ ino: 0,
189
+ nlink: 1,
190
+ rdev: 0,
191
+ blocks: Math.ceil(objInfo.size / 512),
192
+ blksize: 4096,
193
+ etag: objInfo.etag
194
+ };
195
+
196
+ } catch (error) {
197
+ throw ErrorHandler.convertError(error, filePath, 'stat');
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Delete file
203
+ * @param {string} filePath - File path
204
+ * @returns {Promise<void>}
205
+ */
206
+ async unlink(filePath) {
207
+ await this.initialize();
208
+
209
+ try {
210
+ this.pathConverter.validatePath(filePath);
211
+ const { bucket, key } = this.pathConverter.pathToMinIO(filePath);
212
+
213
+ // Check if file exists first
214
+ try {
215
+ await this.minioClient.statObject(bucket, key);
216
+ } catch (error) {
217
+ // If file doesn't exist, throw ENOENT error
218
+ throw ErrorHandler.convertError(error, filePath, 'unlink');
219
+ }
220
+
221
+ // Remove object
222
+ await this.minioClient.removeObject(bucket, key);
223
+
224
+ } catch (error) {
225
+ if (!ErrorHandler.isNotFoundError(error)) {
226
+ throw ErrorHandler.convertError(error, filePath, 'unlink');
227
+ }
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Read directory contents
234
+ * @param {string} dirPath - Directory path
235
+ * @param {Object} [options] - Options
236
+ * @param {boolean} [options.withFileTypes=false] - Return Dirent objects
237
+ * @returns {Promise<string[]|Object[]>} Directory contents
238
+ */
239
+ async readdir(dirPath, options = {}) {
240
+ await this.initialize();
241
+
242
+ try {
243
+ this.pathConverter.validatePath(dirPath);
244
+ const { bucket, prefix } = this.pathConverter.getListPrefix(dirPath);
245
+
246
+ const objects = [];
247
+ const objectStream = this.minioClient.listObjectsV2(bucket, prefix, false);
248
+
249
+ // Collect all objects
250
+ await new Promise((resolve, reject) => {
251
+ objectStream.on('data', (obj) => {
252
+ objects.push(obj);
253
+ });
254
+ objectStream.on('end', resolve);
255
+ objectStream.on('error', reject);
256
+ }); // Extract filenames and remove prefix
257
+ const filenames = objects
258
+ .map(obj => {
259
+ // Handle both regular objects and prefix objects
260
+ let name = obj.name || obj.prefix;
261
+ if (!name) {
262
+ console.log('Warning: Object missing name/prefix property:', obj);
263
+ return null;
264
+ }
265
+ if (prefix && name.startsWith(prefix)) {
266
+ name = name.substring(prefix.length);
267
+ }
268
+ // Remove trailing slash from directory names
269
+ if (name.endsWith('/')) {
270
+ name = name.slice(0, -1);
271
+ }
272
+ return name;
273
+ })
274
+ .filter(name => name !== null && name.length > 0) // Remove null and empty names
275
+ .filter(name => !name.includes('/')) // Only direct children
276
+ .sort();
277
+
278
+ // Return Dirent objects if requested
279
+ if (options.withFileTypes) {
280
+ return filenames.map(name => ({
281
+ name,
282
+ isFile: () => true,
283
+ isDirectory: () => false,
284
+ isBlockDevice: () => false,
285
+ isCharacterDevice: () => false,
286
+ isSymbolicLink: () => false,
287
+ isFIFO: () => false,
288
+ isSocket: () => false
289
+ }));
290
+ }
291
+
292
+ return filenames;
293
+
294
+ } catch (error) {
295
+ throw ErrorHandler.convertError(error, dirPath, 'scandir');
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Create directory (creates directory marker object)
301
+ * @param {string} dirPath - Directory path
302
+ * @param {Object} [options] - Options
303
+ * @param {boolean} [options.recursive=false] - Create parent directories
304
+ * @returns {Promise<void>}
305
+ */
306
+ async mkdir(dirPath, options = {}) {
307
+ await this.initialize();
308
+
309
+ try {
310
+ this.pathConverter.validatePath(dirPath);
311
+ // Create parent directories if recursive option is enabled
312
+ if (options.recursive) {
313
+ const parent = this.pathConverter.getParentPath(dirPath);
314
+ if (parent !== '/' && parent !== dirPath) {
315
+ // Recursively create parent directory without checking if it exists first
316
+ await this.mkdir(parent, { recursive: true });
317
+ }
318
+ }
319
+
320
+ const { bucket, key } = this.pathConverter.createDirectoryMarker(dirPath); // Check if directory already exists
321
+ try {
322
+ await this.minioClient.statObject(bucket, key);
323
+ // Directory already exists
324
+ if (!options.recursive) {
325
+ throw ErrorHandler.createError('EEXIST', dirPath, 'mkdir');
326
+ }
327
+ return;
328
+ } catch (error) {
329
+ // If error is not "Not Found", re-throw it
330
+ if (error.code !== 'NotFound' &&
331
+ !error.message.includes('Not Found') &&
332
+ !ErrorHandler.isNotFoundError(error)) {
333
+ throw ErrorHandler.convertError(error, dirPath, 'mkdir');
334
+ }
335
+ // Otherwise, directory doesn't exist, continue to create it
336
+ }
337
+
338
+ // Create empty directory marker object
339
+ const emptyStream = StreamConverter.toReadableStream('');
340
+ await this.minioClient.putObject(bucket, key, emptyStream, 0);
341
+
342
+ } catch (error) {
343
+ if (!ErrorHandler.isExistsError(error)) {
344
+ throw ErrorHandler.convertError(error, dirPath, 'mkdir');
345
+ }
346
+ throw error;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Remove directory
352
+ * @param {string} dirPath - Directory path
353
+ * @returns {Promise<void>}
354
+ */
355
+ async rmdir(dirPath) {
356
+ await this.initialize();
357
+
358
+ try {
359
+ this.pathConverter.validatePath(dirPath);
360
+
361
+ // Check if directory is empty
362
+ const contents = await this.readdir(dirPath);
363
+ if (contents.length > 0) {
364
+ throw ErrorHandler.createError('ENOTEMPTY', dirPath, 'rmdir');
365
+ }
366
+
367
+ // Remove directory marker
368
+ const { bucket, key } = this.pathConverter.createDirectoryMarker(dirPath);
369
+ await this.minioClient.removeObject(bucket, key);
370
+
371
+ } catch (error) {
372
+ if (!ErrorHandler.isNotFoundError(error) && error.code !== 'ENOTEMPTY') {
373
+ throw ErrorHandler.convertError(error, dirPath, 'rmdir');
374
+ }
375
+ throw error;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Create read stream
381
+ * @param {string} filePath - File path
382
+ * @param {Object} [options] - Stream options
383
+ * @returns {Promise<Readable>} Read stream
384
+ */
385
+ async createReadStream(filePath, options = {}) {
386
+ await this.initialize();
387
+
388
+ try {
389
+ this.pathConverter.validatePath(filePath);
390
+ const { bucket, key } = this.pathConverter.pathToMinIO(filePath);
391
+
392
+ // Get object stream
393
+ const stream = await this.minioClient.getObject(bucket, key);
394
+ return stream;
395
+
396
+ } catch (error) {
397
+ throw ErrorHandler.convertError(error, filePath, 'open');
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Create write stream
403
+ * @param {string} filePath - File path
404
+ * @param {Object} [options] - Stream options
405
+ * @returns {Writable} Write stream
406
+ */
407
+ createWriteStream(filePath, options = {}) {
408
+ const passThrough = StreamConverter.createPassThrough();
409
+
410
+ // Handle the upload asynchronously
411
+ this.initialize()
412
+ .then(() => {
413
+ this.pathConverter.validatePath(filePath);
414
+ const { bucket, key } = this.pathConverter.pathToMinIO(filePath);
415
+
416
+ return this.minioClient.putObject(bucket, key, passThrough);
417
+ })
418
+ .catch(error => {
419
+ passThrough.destroy(ErrorHandler.convertError(error, filePath, 'open'));
420
+ });
421
+
422
+ return passThrough;
423
+ }
424
+
425
+ /**
426
+ * Copy file
427
+ * @param {string} srcPath - Source file path
428
+ * @param {string} destPath - Destination file path
429
+ * @returns {Promise<void>}
430
+ */
431
+ async copyFile(srcPath, destPath) {
432
+ await this.initialize();
433
+
434
+ try {
435
+ this.pathConverter.validatePath(srcPath);
436
+ this.pathConverter.validatePath(destPath);
437
+
438
+ const srcMinIO = this.pathConverter.pathToMinIO(srcPath);
439
+ const destMinIO = this.pathConverter.pathToMinIO(destPath);
440
+
441
+ // Copy object within MinIO
442
+ const copyConditions = new this.minioClient.CopyConditions();
443
+ await this.minioClient.copyObject(
444
+ destMinIO.bucket,
445
+ destMinIO.key,
446
+ `/${srcMinIO.bucket}/${srcMinIO.key}`,
447
+ copyConditions
448
+ );
449
+
450
+ } catch (error) {
451
+ throw ErrorHandler.convertError(error, srcPath, 'open');
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Get MinIO client instance for advanced operations
457
+ * @returns {MinioClient} MinIO client
458
+ */
459
+ getMinioClient() {
460
+ return this.minioClient;
461
+ }
462
+
463
+ /**
464
+ * Get bucket name
465
+ * @returns {string} Bucket name
466
+ */
467
+ getBucket() {
468
+ return this.bucket;
469
+ }
470
+
471
+ /**
472
+ * Get path converter instance
473
+ * @returns {PathConverter} Path converter
474
+ */
475
+ getPathConverter() {
476
+ return this.pathConverter;
477
+ }
478
+ }
479
+
480
+ export default FsMinioClient;
@@ -0,0 +1,209 @@
1
+ // PathConverter.js - Filesystem path to MinIO bucket/key conversion
2
+
3
+ import path from 'path';
4
+
5
+ class PathConverter {
6
+ /**
7
+ * Create PathConverter instance
8
+ * @param {Object} options - Configuration options
9
+ * @param {string} options.bucket - Default bucket name
10
+ * @param {string} [options.prefix=''] - Key prefix for all objects
11
+ * @param {string} [options.separator='/'] - Path separator for object keys
12
+ */
13
+ constructor(options = {}) {
14
+ this.bucket = options.bucket;
15
+ this.prefix = options.prefix || '';
16
+ this.separator = options.separator || '/';
17
+
18
+ if (!this.bucket) {
19
+ throw new Error('Bucket name is required');
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Convert filesystem path to MinIO bucket and key
25
+ * @param {string} filePath - Filesystem path (e.g., '/data/file.txt')
26
+ * @returns {Object} {bucket: string, key: string}
27
+ */
28
+ pathToMinIO(filePath) {
29
+ // Normalize path and remove leading slash
30
+ const normalizedPath = path.posix.normalize(filePath).replace(/^\/+/, '');
31
+
32
+ // Combine prefix and path
33
+ let key = normalizedPath;
34
+ if (this.prefix) {
35
+ key = this.prefix + this.separator + normalizedPath;
36
+ }
37
+
38
+ // Ensure we don't have double separators
39
+ key = key.replace(/\/+/g, '/');
40
+
41
+ return {
42
+ bucket: this.bucket,
43
+ key: key
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Convert MinIO bucket and key back to filesystem path
49
+ * @param {string} bucket - Bucket name
50
+ * @param {string} key - Object key
51
+ * @returns {string} Filesystem path
52
+ */
53
+ minIOToPath(bucket, key) {
54
+ let filePath = key;
55
+
56
+ // Remove prefix if it exists
57
+ if (this.prefix && key.startsWith(this.prefix + this.separator)) {
58
+ filePath = key.substring(this.prefix.length + this.separator.length);
59
+ }
60
+
61
+ // Ensure leading slash for absolute path
62
+ if (!filePath.startsWith('/')) {
63
+ filePath = '/' + filePath;
64
+ }
65
+
66
+ return path.posix.normalize(filePath);
67
+ }
68
+
69
+ /**
70
+ * Get parent directory path
71
+ * @param {string} filePath - File path
72
+ * @returns {string} Parent directory path
73
+ */
74
+ getParentPath(filePath) {
75
+ const normalized = path.posix.normalize(filePath);
76
+ const parent = path.posix.dirname(normalized);
77
+ return parent === '.' ? '/' : parent;
78
+ }
79
+
80
+ /**
81
+ * Check if path represents a directory (ends with separator)
82
+ * @param {string} filePath - Path to check
83
+ * @returns {boolean} True if directory path
84
+ */
85
+ isDirectoryPath(filePath) {
86
+ return filePath.endsWith(this.separator);
87
+ }
88
+
89
+ /**
90
+ * Convert path to directory path (ensure trailing separator)
91
+ * @param {string} filePath - Path to convert
92
+ * @returns {string} Directory path with trailing separator
93
+ */
94
+ toDirectoryPath(filePath) {
95
+ const normalized = path.posix.normalize(filePath);
96
+ return normalized.endsWith(this.separator) ? normalized : normalized + this.separator;
97
+ }
98
+
99
+ /**
100
+ * Get MinIO key for directory listing
101
+ * @param {string} dirPath - Directory path
102
+ * @returns {Object} {bucket: string, prefix: string}
103
+ */
104
+ getListPrefix(dirPath) {
105
+ const { bucket, key } = this.pathToMinIO(dirPath);
106
+
107
+ // For directory listing, we need a prefix that ends with separator
108
+ let prefix = key;
109
+ if (prefix && !prefix.endsWith(this.separator)) {
110
+ prefix += this.separator;
111
+ }
112
+
113
+ return {
114
+ bucket,
115
+ prefix
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Create directory marker key (empty object representing directory)
121
+ * @param {string} dirPath - Directory path
122
+ * @returns {Object} {bucket: string, key: string}
123
+ */
124
+ createDirectoryMarker(dirPath) {
125
+ const dirPathWithSeparator = this.toDirectoryPath(dirPath);
126
+ return this.pathToMinIO(dirPathWithSeparator);
127
+ }
128
+
129
+ /**
130
+ * Extract filename from path
131
+ * @param {string} filePath - Full file path
132
+ * @returns {string} Filename
133
+ */
134
+ getFileName(filePath) {
135
+ return path.posix.basename(filePath);
136
+ }
137
+
138
+ /**
139
+ * Join path segments
140
+ * @param {...string} segments - Path segments to join
141
+ * @returns {string} Joined path
142
+ */
143
+ joinPath(...segments) {
144
+ return path.posix.join(...segments);
145
+ }
146
+
147
+ /**
148
+ * Check if path is absolute
149
+ * @param {string} filePath - Path to check
150
+ * @returns {boolean} True if absolute path
151
+ */
152
+ isAbsolute(filePath) {
153
+ return path.posix.isAbsolute(filePath);
154
+ }
155
+
156
+ /**
157
+ * Resolve relative path to absolute
158
+ * @param {string} basePath - Base path
159
+ * @param {string} relativePath - Relative path
160
+ * @returns {string} Absolute path
161
+ */
162
+ resolve(basePath, relativePath) {
163
+ if (this.isAbsolute(relativePath)) {
164
+ return relativePath;
165
+ }
166
+ return this.joinPath(basePath, relativePath);
167
+ }
168
+
169
+ /**
170
+ * Validate path format
171
+ * @param {string} filePath - Path to validate
172
+ * @throws {Error} If path is invalid
173
+ */
174
+ validatePath(filePath) {
175
+ if (!filePath || typeof filePath !== 'string') {
176
+ throw new Error('Path must be a non-empty string');
177
+ }
178
+
179
+ // Check for invalid characters
180
+ const invalidChars = /[<>:"|?*\x00-\x1f]/;
181
+ if (invalidChars.test(filePath)) {
182
+ throw new Error('Path contains invalid characters');
183
+ }
184
+
185
+ // Check length limits (S3 has 1024 byte limit for keys)
186
+ const { key } = this.pathToMinIO(filePath);
187
+ if (Buffer.byteLength(key, 'utf8') > 1024) {
188
+ throw new Error('Path too long (exceeds 1024 bytes when converted to key)');
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Get bucket name
194
+ * @returns {string} Bucket name
195
+ */
196
+ getBucket() {
197
+ return this.bucket;
198
+ }
199
+
200
+ /**
201
+ * Get prefix
202
+ * @returns {string} Prefix
203
+ */
204
+ getPrefix() {
205
+ return this.prefix;
206
+ }
207
+ }
208
+
209
+ export default PathConverter;