@helia/mfs 0.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/src/index.ts ADDED
@@ -0,0 +1,600 @@
1
+ /**
2
+ * @packageDocumentation
3
+ *
4
+ * `@helia/mfs` is an implementation of a {@link https://docs.ipfs.tech/concepts/file-systems/ Mutable File System} powered by {@link https://github.com/ipfs/helia Helia}.
5
+ *
6
+ * See the {@link MFS MFS interface} for all available operations.
7
+ *
8
+ * @example
9
+ *
10
+ * ```typescript
11
+ * import { createHelia } from 'helia'
12
+ * import { mfs } from '@helia/mfs'
13
+ *
14
+ * const helia = createHelia({
15
+ * // ... helia config
16
+ * })
17
+ * const fs = mfs(helia)
18
+ *
19
+ * // create an empty directory
20
+ * await fs.mkdir('/my-directory')
21
+ *
22
+ * // add a file to the directory
23
+ * await fs.writeBytes(Uint8Array.from([0, 1, 2, 3]), '/my-directory/foo.txt')
24
+ *
25
+ * // read the file
26
+ * for await (const buf of fs.cat('/my-directory/foo.txt')) {
27
+ * console.info(buf)
28
+ * }
29
+ * ```
30
+ */
31
+
32
+ import { unixfs } from '@helia/unixfs'
33
+ import { AlreadyExistsError, DoesNotExistError, InvalidParametersError, NotADirectoryError } from '@helia/unixfs/errors'
34
+ import { logger } from '@libp2p/logger'
35
+ import { Key } from 'interface-datastore'
36
+ import { UnixFS as IPFSUnixFS, type Mtime } from 'ipfs-unixfs'
37
+ import { CID } from 'multiformats/cid'
38
+ import { basename } from './utils/basename.js'
39
+ import type { Blocks } from '@helia/interface/blocks'
40
+ import type { AddOptions, CatOptions, ChmodOptions, CpOptions, LsOptions, MkdirOptions as UnixFsMkdirOptions, RmOptions as UnixFsRmOptions, StatOptions, TouchOptions, UnixFS, UnixFSStats } from '@helia/unixfs'
41
+ import type { AbortOptions } from '@libp2p/interfaces'
42
+ import type { Datastore } from 'interface-datastore'
43
+ import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
44
+ import type { ByteStream } from 'ipfs-unixfs-importer'
45
+
46
+ const log = logger('helia:mfs')
47
+
48
+ export interface MFSComponents {
49
+ blockstore: Blocks
50
+ datastore: Datastore
51
+ }
52
+
53
+ export interface MFSInit {
54
+ /**
55
+ * The key used to store the root CID in the datastore (default: '/local/filesroot')
56
+ */
57
+ key?: string
58
+ }
59
+
60
+ export type WriteOptions = AddOptions & CpOptions & {
61
+ /**
62
+ * An optional mode to set on the new file
63
+ */
64
+ mode: number
65
+
66
+ /**
67
+ * An optional mtime to set on the new file
68
+ */
69
+ mtime: Mtime
70
+ }
71
+
72
+ export type MkdirOptions = AddOptions & StatOptions & CpOptions & UnixFsMkdirOptions
73
+
74
+ /**
75
+ * Options to pass to the rm command
76
+ */
77
+ export interface RmOptions extends UnixFsRmOptions {
78
+ /**
79
+ * If true, allow attempts to delete files or directories that do not exist
80
+ * (default: false)
81
+ */
82
+ force: boolean
83
+ }
84
+
85
+ /**
86
+ * The MFS interface allows working with files and directories in a mutable file
87
+ * system.
88
+ */
89
+ export interface MFS {
90
+ /**
91
+ * Add a single `Uint8Array` to your Helia node as a file.
92
+ *
93
+ * @example
94
+ *
95
+ * ```typescript
96
+ * const cid = await fs.addBytes(Uint8Array.from([0, 1, 2, 3]))
97
+ *
98
+ * console.info(cid)
99
+ * ```
100
+ */
101
+ writeBytes: (bytes: Uint8Array, path: string, options?: Partial<WriteOptions>) => Promise<void>
102
+
103
+ /**
104
+ * Add a stream of `Uint8Array` to your Helia node as a file.
105
+ *
106
+ * @example
107
+ *
108
+ * ```typescript
109
+ * import fs from 'node:fs'
110
+ *
111
+ * const stream = fs.createReadStream('./foo.txt')
112
+ * const cid = await fs.addByteStream(stream)
113
+ *
114
+ * console.info(cid)
115
+ * ```
116
+ */
117
+ writeByteStream: (bytes: ByteStream, path: string, options?: Partial<WriteOptions>) => Promise<void>
118
+
119
+ /**
120
+ * Retrieve the contents of a file from your Helia node.
121
+ *
122
+ * @example
123
+ *
124
+ * ```typescript
125
+ * for await (const buf of fs.cat(cid)) {
126
+ * console.info(buf)
127
+ * }
128
+ * ```
129
+ */
130
+ cat: (path: string, options?: Partial<CatOptions>) => AsyncIterable<Uint8Array>
131
+
132
+ /**
133
+ * Change the permissions on a file or directory in a DAG
134
+ *
135
+ * @example
136
+ *
137
+ * ```typescript
138
+ * const beforeCid = await fs.addBytes(Uint8Array.from([0, 1, 2, 3]))
139
+ * const beforeStats = await fs.stat(beforeCid)
140
+ *
141
+ * const afterCid = await fs.chmod(cid, 0x755)
142
+ * const afterStats = await fs.stat(afterCid)
143
+ *
144
+ * console.info(beforeCid, beforeStats)
145
+ * console.info(afterCid, afterStats)
146
+ * ```
147
+ */
148
+ chmod: (path: string, mode: number, options?: Partial<ChmodOptions>) => Promise<void>
149
+
150
+ /**
151
+ * Add a file or directory to a target directory.
152
+ *
153
+ * @example
154
+ *
155
+ * ```typescript
156
+ * const fileCid = await fs.addBytes(Uint8Array.from([0, 1, 2, 3]))
157
+ * const directoryCid = await fs.addDirectory()
158
+ *
159
+ * const updatedCid = await fs.cp(fileCid, directoryCid, 'foo.txt')
160
+ *
161
+ * console.info(updatedCid)
162
+ * ```
163
+ */
164
+ cp: (source: CID | string, destination: string, options?: Partial<CpOptions>) => Promise<void>
165
+
166
+ /**
167
+ * List directory contents.
168
+ *
169
+ * @example
170
+ *
171
+ * ```typescript
172
+ * for await (const entry of fs.ls(directoryCid)) {
173
+ * console.info(etnry)
174
+ * }
175
+ * ```
176
+ */
177
+ ls: (path?: string, options?: Partial<LsOptions>) => AsyncIterable<UnixFSEntry>
178
+
179
+ /**
180
+ * Make a new directory under an existing directory.
181
+ *
182
+ * @example
183
+ *
184
+ * ```typescript
185
+ * const directoryCid = await fs.addDirectory()
186
+ *
187
+ * const updatedCid = await fs.mkdir(directoryCid, 'new-dir')
188
+ *
189
+ * console.info(updatedCid)
190
+ * ```
191
+ */
192
+ mkdir: (path: string, options?: Partial<MkdirOptions>) => Promise<void>
193
+
194
+ /**
195
+ * Remove a file or directory from an existing directory.
196
+ *
197
+ * @example
198
+ *
199
+ * ```typescript
200
+ * const directoryCid = await fs.addDirectory()
201
+ * const updatedCid = await fs.mkdir(directoryCid, 'new-dir')
202
+ *
203
+ * const finalCid = await fs.rm(updatedCid, 'new-dir')
204
+ *
205
+ * console.info(finalCid)
206
+ * ```
207
+ */
208
+ rm: (path: string, options?: Partial<RmOptions>) => Promise<void>
209
+
210
+ /**
211
+ * Return statistics about a UnixFS DAG.
212
+ *
213
+ * @example
214
+ *
215
+ * ```typescript
216
+ * const fileCid = await fs.addBytes(Uint8Array.from([0, 1, 2, 3]))
217
+ *
218
+ * const stats = await fs.stat(fileCid)
219
+ *
220
+ * console.info(stats)
221
+ * ```
222
+ */
223
+ stat: (path: string, options?: Partial<StatOptions>) => Promise<UnixFSStats>
224
+
225
+ /**
226
+ * Update the mtime of a UnixFS DAG
227
+ *
228
+ * @example
229
+ *
230
+ * ```typescript
231
+ * const beforeCid = await fs.addBytes(Uint8Array.from([0, 1, 2, 3]))
232
+ * const beforeStats = await fs.stat(beforeCid)
233
+ *
234
+ * const afterCid = await fs.touch(beforeCid)
235
+ * const afterStats = await fs.stat(afterCid)
236
+ *
237
+ * console.info(beforeCid, beforeStats)
238
+ * console.info(afterCid, afterStats)
239
+ * ```
240
+ */
241
+ touch: (path: string, options?: Partial<TouchOptions>) => Promise<void>
242
+ }
243
+
244
+ interface PathEntry {
245
+ cid: CID
246
+ name: string
247
+ unixfs?: IPFSUnixFS
248
+ }
249
+
250
+ interface WalkPathOptions extends AbortOptions {
251
+ createMissingDirectories: boolean
252
+ finalSegmentMustBeDirectory: boolean
253
+ }
254
+
255
+ class DefaultMFS implements MFS {
256
+ private readonly components: MFSComponents
257
+ private readonly unixfs: UnixFS
258
+ private root?: CID
259
+ private readonly key: Key
260
+
261
+ constructor (components: MFSComponents, init: MFSInit = {}) {
262
+ this.components = components
263
+
264
+ this.key = new Key(init.key ?? '/locals/filesroot')
265
+ this.unixfs = unixfs(components)
266
+ }
267
+
268
+ async #getRootCID (): Promise<CID> {
269
+ if (this.root == null) {
270
+ try {
271
+ const buf = await this.components.datastore.get(this.key)
272
+ this.root = CID.decode(buf)
273
+ } catch (err: any) {
274
+ if (err.code !== 'ERR_NOT_FOUND') {
275
+ throw err
276
+ }
277
+
278
+ this.root = await this.unixfs.addDirectory()
279
+ }
280
+ }
281
+
282
+ return this.root
283
+ }
284
+
285
+ async writeBytes (bytes: Uint8Array, path: string, options?: Partial<WriteOptions>): Promise<void> {
286
+ const cid = await this.unixfs.addFile({
287
+ content: bytes,
288
+ mode: options?.mode,
289
+ mtime: options?.mtime
290
+ }, options)
291
+
292
+ await this.cp(cid, path, options)
293
+ }
294
+
295
+ async writeByteStream (bytes: ByteStream, path: string, options?: Partial<WriteOptions>): Promise<void> {
296
+ const cid = await this.unixfs.addFile({
297
+ content: bytes,
298
+ mode: options?.mode,
299
+ mtime: options?.mtime
300
+ }, options)
301
+
302
+ await this.cp(cid, path, options)
303
+ }
304
+
305
+ async * cat (path: string, options: Partial<CatOptions> = {}): AsyncIterable<Uint8Array> {
306
+ const root = await this.#getRootCID()
307
+ const trail = await this.#walkPath(root, path, {
308
+ ...options,
309
+ createMissingDirectories: false,
310
+ finalSegmentMustBeDirectory: false
311
+ })
312
+
313
+ yield * this.unixfs.cat(trail[trail.length - 1].cid, options)
314
+ }
315
+
316
+ async chmod (path: string, mode: number, options: Partial<ChmodOptions> = {}): Promise<void> {
317
+ const root = await this.#getRootCID()
318
+
319
+ this.root = await this.unixfs.chmod(root, mode, {
320
+ ...options,
321
+ path
322
+ })
323
+ }
324
+
325
+ async cp (source: CID | string, destination: string, options?: Partial<CpOptions>): Promise<void> {
326
+ const root = await this.#getRootCID()
327
+ const force = options?.force ?? false
328
+
329
+ if (typeof source === 'string') {
330
+ const stat = await this.stat(source, options)
331
+
332
+ source = stat.cid
333
+ }
334
+
335
+ if (!force) {
336
+ await this.#ensurePathDoesNotExist(destination, options)
337
+ }
338
+
339
+ const fileName = basename(destination)
340
+ const containingDirectory = destination.substring(0, destination.length - `/${fileName}`.length)
341
+
342
+ let trail: PathEntry[] = [{
343
+ cid: root,
344
+ name: ''
345
+ }]
346
+
347
+ if (containingDirectory !== '') {
348
+ trail = await this.#walkPath(root, containingDirectory, {
349
+ ...options,
350
+ createMissingDirectories: options?.force ?? false,
351
+ finalSegmentMustBeDirectory: true
352
+ })
353
+ }
354
+
355
+ trail.push({
356
+ cid: source,
357
+ name: fileName
358
+ })
359
+
360
+ this.root = await this.#persistPath(trail, options)
361
+ }
362
+
363
+ async * ls (path?: string, options?: Partial<LsOptions>): AsyncIterable<UnixFSEntry> {
364
+ const root = await this.#getRootCID()
365
+
366
+ if (options?.path != null) {
367
+ path = `${path}/${options.path}`
368
+ }
369
+
370
+ yield * this.unixfs.ls(root, {
371
+ ...options,
372
+ path
373
+ })
374
+ }
375
+
376
+ async mkdir (path: string, options?: Partial<MkdirOptions>): Promise<void> {
377
+ const force = options?.force ?? false
378
+
379
+ if (!force) {
380
+ await this.#ensurePathDoesNotExist(path, options)
381
+ }
382
+
383
+ const dirName = basename(path)
384
+ const containingDirectory = path.substring(0, path.length - `/${dirName}`.length)
385
+ const root = await this.#getRootCID()
386
+
387
+ let trail: PathEntry[] = [{
388
+ cid: root,
389
+ name: ''
390
+ }]
391
+
392
+ if (containingDirectory !== '') {
393
+ trail = await this.#walkPath(root, containingDirectory, {
394
+ ...options,
395
+ createMissingDirectories: force,
396
+ finalSegmentMustBeDirectory: true
397
+ })
398
+ }
399
+
400
+ trail.push({
401
+ cid: await this.unixfs.addDirectory({
402
+ mode: options?.mode,
403
+ mtime: options?.mtime
404
+ }, options),
405
+ name: basename(path)
406
+ })
407
+
408
+ this.root = await this.#persistPath(trail, options)
409
+ }
410
+
411
+ async rm (path: string, options?: Partial<RmOptions>): Promise<void> {
412
+ const force = options?.force ?? false
413
+
414
+ if (!force) {
415
+ await this.#ensurePathExists(path, options)
416
+ }
417
+
418
+ const root = await this.#getRootCID()
419
+
420
+ const trail = await this.#walkPath(root, path, {
421
+ ...options,
422
+ createMissingDirectories: false,
423
+ finalSegmentMustBeDirectory: false
424
+ })
425
+
426
+ const lastSegment = trail.pop()
427
+
428
+ if (lastSegment == null) {
429
+ throw new InvalidParametersError('path was too short')
430
+ }
431
+
432
+ // remove directory entry
433
+ const containingDir = trail[trail.length - 1]
434
+ containingDir.cid = await this.unixfs.rm(containingDir.cid, lastSegment.name, options)
435
+
436
+ this.root = await this.#persistPath(trail, options)
437
+ }
438
+
439
+ async stat (path: string, options?: Partial<StatOptions>): Promise<UnixFSStats> {
440
+ const root = await this.#getRootCID()
441
+
442
+ const trail = await this.#walkPath(root, path, {
443
+ ...options,
444
+ createMissingDirectories: false,
445
+ finalSegmentMustBeDirectory: false
446
+ })
447
+
448
+ const finalEntry = trail.pop()
449
+
450
+ if (finalEntry == null) {
451
+ throw new DoesNotExistError()
452
+ }
453
+
454
+ return this.unixfs.stat(finalEntry.cid, {
455
+ ...options
456
+ })
457
+ }
458
+
459
+ async touch (path: string, options?: Partial<TouchOptions>): Promise<void> {
460
+ const root = await this.#getRootCID()
461
+ const trail = await this.#walkPath(root, path, {
462
+ ...options,
463
+ createMissingDirectories: false,
464
+ finalSegmentMustBeDirectory: false
465
+ })
466
+
467
+ const finalEntry = trail[trail.length - 1]
468
+
469
+ if (finalEntry == null) {
470
+ throw new DoesNotExistError()
471
+ }
472
+
473
+ finalEntry.cid = await this.unixfs.touch(finalEntry.cid, options)
474
+
475
+ this.root = await this.#persistPath(trail, options)
476
+ }
477
+
478
+ async #walkPath (root: CID, path: string, opts: WalkPathOptions): Promise<PathEntry[]> {
479
+ if (!path.startsWith('/')) {
480
+ throw new InvalidParametersError('path must be absolute')
481
+ }
482
+
483
+ const stat = await this.unixfs.stat(root, {
484
+ ...opts,
485
+ offline: true
486
+ })
487
+
488
+ const output: PathEntry[] = [{
489
+ cid: root,
490
+ name: '',
491
+ unixfs: stat.unixfs
492
+ }]
493
+
494
+ let cid = root
495
+ const parts = path.split('/').filter(Boolean)
496
+
497
+ for (let i = 0; i < parts.length; i++) {
498
+ const segment = parts[i]
499
+
500
+ try {
501
+ const stat = await this.unixfs.stat(cid, {
502
+ ...opts,
503
+ offline: true,
504
+ path: segment
505
+ })
506
+
507
+ output.push({
508
+ cid: stat.cid,
509
+ name: segment,
510
+ unixfs: stat.unixfs
511
+ })
512
+
513
+ cid = stat.cid
514
+ } catch (err) {
515
+ log.error('could not resolve path segment %s of %s under %c', segment, path, root)
516
+
517
+ if (opts.createMissingDirectories) {
518
+ const cid = await this.unixfs.addDirectory()
519
+
520
+ output.push({
521
+ cid,
522
+ name: segment,
523
+ unixfs: new IPFSUnixFS({ type: 'directory' })
524
+ })
525
+ } else {
526
+ throw new DoesNotExistError(`${path} does not exist`)
527
+ }
528
+ }
529
+ }
530
+
531
+ const lastSegment = output[output.length - 1]
532
+
533
+ if (opts.finalSegmentMustBeDirectory && lastSegment.unixfs?.isDirectory() !== true) {
534
+ throw new NotADirectoryError(`${path} was not a directory`)
535
+ }
536
+
537
+ return output
538
+ }
539
+
540
+ async #persistPath (path: PathEntry[], options: Partial<CpOptions> = {}): Promise<CID> {
541
+ let child = path.pop()
542
+
543
+ if (child == null) {
544
+ throw new InvalidParametersError('path was too short')
545
+ }
546
+
547
+ let cid = child.cid
548
+
549
+ for (let i = path.length - 1; i > -1; i--) {
550
+ const segment = path[i]
551
+ segment.cid = await this.unixfs.cp(child.cid, segment.cid, child.name, {
552
+ ...options,
553
+ force: true
554
+ })
555
+
556
+ child = segment
557
+ cid = segment.cid
558
+ }
559
+
560
+ await this.components.datastore.put(this.key, cid.bytes, options)
561
+
562
+ return cid
563
+ }
564
+
565
+ async #ensurePathExists (path: string, options: StatOptions = {}): Promise<void> {
566
+ const exists = await this.#pathExists(path, options)
567
+
568
+ if (!exists) {
569
+ throw new DoesNotExistError()
570
+ }
571
+ }
572
+
573
+ async #ensurePathDoesNotExist (path: string, options: StatOptions = {}): Promise<void> {
574
+ const exists = await this.#pathExists(path, options)
575
+
576
+ if (exists) {
577
+ throw new AlreadyExistsError()
578
+ }
579
+ }
580
+
581
+ async #pathExists (path: string, options: StatOptions = {}): Promise<boolean> {
582
+ try {
583
+ await this.stat(path, {
584
+ ...options,
585
+ offline: true
586
+ })
587
+
588
+ return true
589
+ } catch {
590
+ return false
591
+ }
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Create a {@link MFS} instance powered by {@link https://github.com/ipfs/helia Helia}
597
+ */
598
+ export function mfs (helia: { blockstore: Blocks, datastore: Datastore }, init: MFSInit = {}): MFS {
599
+ return new DefaultMFS(helia, init)
600
+ }
@@ -0,0 +1,3 @@
1
+ export function basename (path: string): string {
2
+ return path.split('/').pop() ?? ''
3
+ }