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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
291 lines · 9.6 KB Raw History
import {
  CID,
  cborEncodeDagCbor,
  cidToBytes,
  cidToString,
  createBlobCid,
  createCid,
} from '@pdsjs/core/repo';
import { beforeEach, describe, expect, it } from 'vitest';
import { clearManifestCache, loadSite } from '../src/manifest.js';

const DID = 'did:plc:sitetest';

/**
 * Build a blob ref the way records carry one, from arbitrary content bytes.
 * @param {string} content
 * @param {string} mimeType
 */
async function blobRef(content, mimeType) {
  const bytes = new TextEncoder().encode(content);
  const cid = await createBlobCid(bytes);
  return {
    $type: 'blob',
    ref: new CID(cid),
    mimeType,
    size: bytes.length,
    cidString: cidToString(cid),
  };
}

/** @param {{$type: string, ref: CID, mimeType: string, size: number, cidString: string}} ref */
function stripCidString({ cidString, ...blob }) {
  return blob;
}

/**
 * In-memory actorStorage covering just getRecord, keyed by AT-URI.
 * @param {Record<string, object>} records - uri → record value (pre-encode)
 */
async function mockStorage(records) {
  /** @type {Map<string, {cid: string, value: Uint8Array}>} */
  const encoded = new Map();
  for (const [uri, value] of Object.entries(records)) {
    const bytes = cborEncodeDagCbor(value);
    encoded.set(uri, {
      cid: cidToString(await createCid(bytes)),
      value: bytes,
    });
  }
  return {
    storage: /** @type {any} */ ({
      getRecord: async (/** @type {string} */ uri) => encoded.get(uri) || null,
    }),
    encoded,
  };
}

/** @param {string} name */
const uri = (name) => `at://${DID}/dev.pdsjs.site.deploy/${name}`;

describe('loadSite', () => {
  beforeEach(() => clearManifestCache());

  it('returns null for a missing site', async () => {
    const { storage } = await mockStorage({});
    expect(await loadSite(storage, DID, 'nope')).toBeNull();
  });

  it('builds a path map with CID strings and metadata', async () => {
    const index = await blobRef('<html>hi</html>', 'text/html');
    const css = await blobRef('body{}', 'text/css');
    const { storage } = await mockStorage({
      [uri('home')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [
          { path: 'index.html', blob: stripCidString(index) },
          {
            path: '/assets/site.css',
            blob: stripCidString(css),
            contentType: 'text/css; charset=utf-8',
            cacheControl: 'public, max-age=31536000, immutable',
          },
        ],
        createdAt: '2026-08-06T00:00:00.000Z',
      },
    });

    const site = await loadSite(storage, DID, 'home');
    expect(site).not.toBeNull();
    expect(site?.createdAt).toBe('2026-08-06T00:00:00.000Z');
    expect(site?.files.get('index.html')).toEqual({
      cid: index.cidString,
      mimeType: 'text/html',
      size: index.size,
    });
    // Leading slashes are normalized away; overrides carried through
    expect(site?.files.get('assets/site.css')).toMatchObject({
      cid: css.cidString,
      contentType: 'text/css; charset=utf-8',
      cacheControl: 'public, max-age=31536000, immutable',
    });
    expect(site?.fallback).toBeNull();
  });

  it('keeps fallback only when it resolves to a file', async () => {
    const index = await blobRef('<html/>', 'text/html');
    const { storage } = await mockStorage({
      [uri('app')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [{ path: 'index.html', blob: stripCidString(index) }],
        fallback: '/index.html',
        createdAt: '2026-08-06T00:00:00.000Z',
      },
      [uri('broken')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [{ path: 'index.html', blob: stripCidString(index) }],
        fallback: 'missing.html',
        createdAt: '2026-08-06T00:00:00.000Z',
      },
    });

    expect((await loadSite(storage, DID, 'app'))?.fallback).toBe('index.html');
    expect((await loadSite(storage, DID, 'broken'))?.fallback).toBeNull();
  });

  it('skips malformed file entries rather than failing the site', async () => {
    const good = await blobRef('ok', 'text/plain');
    const { storage } = await mockStorage({
      [uri('home')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [
          { path: 'good.txt', blob: stripCidString(good) },
          { path: '', blob: stripCidString(good) },
          { path: 'no-blob.txt' },
          { blob: stripCidString(good) },
        ],
        createdAt: '2026-08-06T00:00:00.000Z',
      },
    });

    const site = await loadSite(storage, DID, 'home');
    expect(site?.files.size).toBe(1);
    expect(site?.files.has('good.txt')).toBe(true);
  });

  it('reads refs in their XRPC shape ({$link}), the write path default', async () => {
    // putRecord over JSON stores blob refs as `{$link}` maps, not tag-42
    // CIDs — the same duality findBlobRefs handles. A CLI-deployed site is
    // entirely this shape.
    const data = new TextEncoder().encode('<h1>hi</h1>');
    const cid = cidToString(await createBlobCid(data));
    const { storage } = await mockStorage({
      [uri('home')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [
          {
            path: 'index.html',
            blob: {
              $type: 'blob',
              ref: { $link: cid },
              mimeType: 'text/html',
              size: data.length,
            },
          },
        ],
        createdAt: '2026-08-06T00:00:00.000Z',
      },
    });

    const site = await loadSite(storage, DID, 'home');
    expect(site?.files.get('index.html')).toMatchObject({
      cid,
      mimeType: 'text/html',
      size: data.length,
    });
  });

  it('merges a base record beneath the site record, one hop', async () => {
    const shared = await blobRef('shared', 'text/css');
    const oldIndex = await blobRef('old', 'text/html');
    const newIndex = await blobRef('new', 'text/html');
    const { storage, encoded } = await mockStorage({
      [uri('assets')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [
          { path: 'shared.css', blob: stripCidString(shared) },
          { path: 'index.html', blob: stripCidString(oldIndex) },
        ],
        createdAt: '2026-08-06T00:00:00.000Z',
      },
    });
    const baseCid = encoded.get(uri('assets'))?.cid;
    const topRecord = {
      $type: 'dev.pdsjs.site.deploy',
      files: [{ path: 'index.html', blob: stripCidString(newIndex) }],
      base: { uri: uri('assets'), cid: baseCid },
      createdAt: '2026-08-06T00:00:00.000Z',
    };
    const topBytes = cborEncodeDagCbor(topRecord);
    encoded.set(uri('home'), {
      cid: cidToString(await createCid(topBytes)),
      value: topBytes,
    });

    const site = await loadSite(storage, DID, 'home');
    // Base contributes what the top record lacks; top wins on conflict
    expect(site?.files.get('shared.css')?.cid).toBe(shared.cidString);
    expect(site?.files.get('index.html')?.cid).toBe(newIndex.cidString);
  });

  it('refuses a base chain deeper than one hop', async () => {
    const file = await blobRef('x', 'text/plain');
    const records = /** @type {Record<string, object>} */ ({});
    records[uri('deep')] = {
      $type: 'dev.pdsjs.site.deploy',
      files: [{ path: 'a.txt', blob: stripCidString(file) }],
      createdAt: '2026-08-06T00:00:00.000Z',
    };
    records[uri('mid')] = {
      $type: 'dev.pdsjs.site.deploy',
      files: [],
      base: { uri: uri('deep'), cid: 'bafyfake' },
      createdAt: '2026-08-06T00:00:00.000Z',
    };
    records[uri('top')] = {
      $type: 'dev.pdsjs.site.deploy',
      files: [],
      base: { uri: uri('mid'), cid: 'bafyfake' },
      createdAt: '2026-08-06T00:00:00.000Z',
    };
    const { storage } = await mockStorage(records);

    await expect(loadSite(storage, DID, 'top')).rejects.toThrow(/one hop/);
  });

  it('caches by record CID and invalidates when the base changes', async () => {
    const v1 = await blobRef('v1', 'text/html');
    const v2 = await blobRef('v2', 'text/html');
    const { storage, encoded } = await mockStorage({
      [uri('assets')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [{ path: 'base.txt', blob: stripCidString(v1) }],
        createdAt: '2026-08-06T00:00:00.000Z',
      },
      [uri('home')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [{ path: 'index.html', blob: stripCidString(v1) }],
        base: { uri: uri('assets'), cid: 'bafypin' },
        createdAt: '2026-08-06T00:00:00.000Z',
      },
    });

    const first = await loadSite(storage, DID, 'home');
    const again = await loadSite(storage, DID, 'home');
    // Same record CIDs → the exact cached object comes back
    expect(again).toBe(first);

    // Redeploy the base only; the merged map must rebuild
    const newBase = {
      $type: 'dev.pdsjs.site.deploy',
      files: [{ path: 'base.txt', blob: stripCidString(v2) }],
      createdAt: '2026-08-06T00:00:00.000Z',
    };
    const newBytes = cborEncodeDagCbor(newBase);
    encoded.set(uri('assets'), {
      cid: cidToString(await createCid(newBytes)),
      value: newBytes,
    });

    const rebuilt = await loadSite(storage, DID, 'home');
    expect(rebuilt).not.toBe(first);
    expect(rebuilt?.files.get('base.txt')?.cid).toBe(v2.cidString);
  });

  it('round-trips CID bytes through encode and decode', async () => {
    const file = await blobRef('bytes', 'application/wasm');
    const { storage } = await mockStorage({
      [uri('home')]: {
        $type: 'dev.pdsjs.site.deploy',
        files: [{ path: 'm.wasm', blob: stripCidString(file) }],
        createdAt: '2026-08-06T00:00:00.000Z',
      },
    });

    const site = await loadSite(storage, DID, 'home');
    const cid = site?.files.get('m.wasm')?.cid;
    expect(cid).toBe(file.cidString);
    // The string form must decode back to the same bytes it came from
    expect(cidToString(cidToBytes(/** @type {string} */ (cid)))).toBe(cid);
  });
});