~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
227 lines · 5.9 KB Raw History
// @pdsjs/blobs-fs - Filesystem blob storage

import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import {
  mkdir,
  open,
  readdir,
  readFile,
  rename,
  unlink,
  writeFile,
} from 'node:fs/promises';
import { dirname, join, relative, sep } from 'node:path';
import { Readable } from 'node:stream';

/**
 * @typedef {import('@pdsjs/core/ports').BlobPort} BlobPort
 * @typedef {import('@pdsjs/core/ports').BackupTargetPort} BackupTargetPort
 */

/**
 * Create filesystem blob adapter
 * @param {string} baseDir - Base directory for blob storage
 * @returns {BlobPort}
 */
export function createFsBlobs(baseDir) {
  /**
   * @param {string} did
   * @param {string} cid
   * @returns {string}
   */
  function blobPath(did, cid) {
    // Use first 2 chars of CID for sharding
    const shard = cid.slice(1, 3);
    return join(baseDir, did, shard, cid);
  }

  return {
    /**
     * @param {string} did
     * @param {string} cid
     */
    async get(did, cid) {
      try {
        const path = blobPath(did, cid);
        const data = await readFile(path);
        const metaPath = `${path}.meta`;
        const meta = JSON.parse(await readFile(metaPath, 'utf8'));
        return { data: new Uint8Array(data), mimeType: meta.mimeType };
      } catch (e) {
        if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT')
          return null;
        throw e;
      }
    },

    /**
     * @param {string} did
     * @param {string} cid
     * @param {Uint8Array} data
     * @param {string} mimeType
     */
    async put(did, cid, data, mimeType) {
      const path = blobPath(did, cid);
      await mkdir(dirname(path), { recursive: true });
      await writeFile(path, data);
      await writeFile(`${path}.meta`, JSON.stringify({ mimeType }));
    },

    /**
     * @param {string} did
     * @param {string} cid
     */
    async delete(did, cid) {
      const path = blobPath(did, cid);
      try {
        await unlink(path);
        await unlink(`${path}.meta`);
      } catch (e) {
        if (/** @type {NodeJS.ErrnoException} */ (e).code !== 'ENOENT') throw e;
      }
    },

    /**
     * @param {string} did
     * @param {string} cid
     * @param {{start: number, end: number}} [range] - Inclusive byte positions
     */
    async getStream(did, cid, range) {
      const path = blobPath(did, cid);
      /** @type {{mimeType: string}} */
      let meta;
      try {
        meta = JSON.parse(await readFile(`${path}.meta`, 'utf8'));
      } catch (e) {
        if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT') {
          return null;
        }
        throw e;
      }
      const stream = createReadStream(
        path,
        range ? { start: range.start, end: range.end } : undefined,
      );
      return {
        body: /** @type {ReadableStream<Uint8Array>} */ (
          Readable.toWeb(stream)
        ),
        mimeType: meta.mimeType,
      };
    },

    /**
     * Bytes stage in a temporary file and move under the CID at commit. The
     * `.tmp` directory sits beside the shard directories; a shard name is two
     * CID characters, so the names never collide.
     * @param {string} did
     */
    async createWriter(did) {
      const dir = join(baseDir, did, '.tmp');
      await mkdir(dir, { recursive: true });
      const tempPath = join(dir, randomUUID());
      const handle = await open(tempPath, 'w');
      return {
        /** @param {Uint8Array} chunk */
        async write(chunk) {
          await handle.write(chunk);
        },
        /**
         * @param {string} cid
         * @param {string} mimeType
         */
        async commit(cid, mimeType) {
          await handle.close();
          const path = blobPath(did, cid);
          await mkdir(dirname(path), { recursive: true });
          await rename(tempPath, path);
          await writeFile(`${path}.meta`, JSON.stringify({ mimeType }));
        },
        async abort() {
          await handle.close();
          try {
            await unlink(tempPath);
          } catch (e) {
            if (/** @type {NodeJS.ErrnoException} */ (e).code !== 'ENOENT') {
              throw e;
            }
          }
        },
      };
    },
  };
}

/**
 * Backups in a directory — ideally one on a different disk than the PDS
 * itself. Keys map straight to paths under `baseDir`; content types have
 * nowhere to live on a filesystem and are dropped.
 * @param {string} baseDir
 * @returns {BackupTargetPort}
 */
export function createFsBackupTarget(baseDir) {
  /**
   * @param {string} key
   * @returns {string}
   */
  function keyPath(key) {
    return join(baseDir, ...key.split('/'));
  }

  return {
    /**
     * @param {string} key
     * @param {Uint8Array} data
     */
    async put(key, data) {
      const path = keyPath(key);
      await mkdir(dirname(path), { recursive: true });
      await writeFile(path, data);
    },

    /** @param {string} key */
    async get(key) {
      try {
        return new Uint8Array(await readFile(keyPath(key)));
      } catch (e) {
        if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT')
          return null;
        throw e;
      }
    },

    /** @param {string} prefix */
    async list(prefix) {
      /** @type {import('node:fs').Dirent[]} */
      let entries;
      try {
        entries = await readdir(baseDir, {
          recursive: true,
          withFileTypes: true,
        });
      } catch (e) {
        if (/** @type {NodeJS.ErrnoException} */ (e).code === 'ENOENT')
          return [];
        throw e;
      }
      return entries
        .filter((entry) => entry.isFile())
        .map((entry) =>
          relative(baseDir, join(entry.parentPath, entry.name))
            .split(sep)
            .join('/'),
        )
        .filter((key) => key.startsWith(prefix));
    },

    /** @param {string} key */
    async delete(key) {
      try {
        await unlink(keyPath(key));
      } catch (e) {
        if (/** @type {NodeJS.ErrnoException} */ (e).code !== 'ENOENT') throw e;
      }
    },
  };
}