chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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);
});
});