chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// A video gallery: every video in the owner's public Drive, played straight
// from the repo. Deploy it to your own PDS with PDS_URL, PDS_DID and
// PDS_APP_PASSWORD set; see docs/apps.md.
//
// Videos are dev.pdsjs.drive.file records whose one part is a video blob.
// Drop a video into the Drive section of the account page and it appears
// here; the same blob CID can back a Bluesky post embed or a Spark video.
// Playback uses the site's /.blobs/<cid> route, which serves byte ranges,
// so seeking never downloads the whole file.
//
// Usage: PDS_APP_PASSWORD=... node videos.mjs
const PDS = process.env.PDS_URL;
const DID = process.env.PDS_DID;
if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.');
const SITE = 'videos-app';
const SITES = 'dev.pdsjs.site.deploy';
const DEFS = 'dev.pdsjs.query.def';
const FILES = 'dev.pdsjs.drive.file';
async function xrpc(path, init = {}) {
const response = await fetch(`${PDS}/xrpc/${path}`, init);
if (!response.ok) {
throw new Error(
`${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`,
);
}
return response.json();
}
const password = process.env.PDS_APP_PASSWORD;
if (!password) throw new Error('Set PDS_APP_PASSWORD.');
const session = await xrpc('com.atproto.server.createSession', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: DID, password }),
});
const auth = { Authorization: `Bearer ${session.accessJwt}` };
/** @param {string} collection @param {string} rkey @param {Record<string, unknown>} record */
async function put(collection, rkey, record) {
await xrpc('com.atproto.repo.putRecord', {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
repo: DID,
collection,
rkey,
record: { $type: collection, ...record },
}),
});
console.error(`put ${collection}/${rkey}`);
}
// ---- the query the app reads ----
await put(DEFS, 'gallery-videos', {
collection: FILES,
sort: { field: 'createdAt', direction: 'desc' },
createdAt: new Date().toISOString(),
});
// ---- the app ----
const HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Videos</title>
<meta name="description" content="Videos kept as files in an atproto repo, played straight from it.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230e0e11'/%3E%3Cpath d='M26 20l18 12-18 12z' fill='%235b8cff'/%3E%3C/svg%3E">
<style>
:root{color-scheme:dark;--bg:#0e0e11;--card:#17171c;--line:#26262e;--fg:#ececf1;--dim:#9a9aa4;--accent:#5b8cff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,sans-serif;line-height:1.5;-webkit-text-size-adjust:100%}
a{color:inherit;text-decoration:none}
button{font:inherit;cursor:pointer}
.wrap{max-width:1080px;margin:0 auto;padding:0 18px 80px}
header.site{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:30px 0 18px;flex-wrap:wrap}
header.site h1{margin:0;font-size:23px;letter-spacing:-.02em}
header.site .who{color:var(--dim);font-size:13px}
.btn{border:0;background:var(--accent);color:#07142e;padding:9px 16px;border-radius:10px;font-weight:650;font-size:14px}
.btn:hover{filter:brightness(1.08)}
.btn.ghost{background:transparent;color:var(--dim);border:1px solid var(--line)}
.btn.ghost:hover{color:var(--fg)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-top:8px}
.tile{display:block;border-radius:12px;overflow:hidden;background:var(--card);border:1px solid var(--line)}
.tile video{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000;pointer-events:none}
.tile .label{padding:9px 12px 11px;font-size:13.5px}
.tile .label .sub{color:var(--dim);font-size:12px;margin-top:1px}
.state{color:var(--dim);padding:44px 0;text-align:center}
.detail .frame{background:#000;border:1px solid var(--line);border-radius:14px;overflow:hidden}
.detail video{display:block;width:100%;max-height:78vh}
.detail h2{margin:16px 0 0;font-size:19px}
.detail .meta{display:flex;flex-wrap:wrap;gap:8px 18px;color:var(--dim);font-size:13px;margin-top:8px}
.detail .acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
nav.back{padding:26px 0 0;font-size:14px;color:var(--dim)}
nav.back a:hover{color:var(--fg)}
#toast{position:fixed;left:50%;bottom:26px;transform:translate(-50%,14px);background:#1f1f27;border:1px solid var(--line);color:var(--fg);padding:10px 18px;border-radius:999px;font-size:14px;opacity:0;transition:opacity .18s,transform .18s;pointer-events:none;z-index:40}
#toast.on{opacity:1;transform:translate(-50%,0)}
footer{margin-top:52px;color:#6b6b74;font-size:12.5px;text-align:center}
footer a{color:var(--dim)}
</style>
</head>
<body>
<div class="wrap" id="app"><p class="state">Loading…</p></div>
<div id="toast"></div>
<script>
const FILES = ${JSON.stringify(FILES)};
let PDS = null;
let DID = null;
let HANDLE = null;
let LIST = [];
async function discover() {
DID = (await (await fetch('/.well-known/atproto-did')).text()).trim();
const override = new URLSearchParams(location.search).get('pds');
if (override) {
PDS = override.endsWith('/') ? override.slice(0, -1) : override;
} else {
const doc = await (await fetch('https://plc.directory/' + DID)).json();
PDS = (doc.service || []).find(
(s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer',
).serviceEndpoint;
}
try {
const repo = await (await fetch(PDS + '/xrpc/com.atproto.repo.describeRepo?repo=' + DID)).json();
HANDLE = repo.handle || DID;
} catch { HANDLE = DID; }
}
// A gallery row: a live drive file whose one part is a video blob. A split
// file has no single blob to play; a trashed file carries deletedAt.
function entryOf(uri, value) {
if (value.deletedAt) return null;
const parts = value.parts || [];
if (parts.length !== 1) return null;
const part = parts[0];
const mime = part.mimeType || value.mimeType || '';
if (!mime.startsWith('video/')) return null;
const ref = part.ref || {};
const cid = ref.$link || (typeof ref === 'string' ? ref : null);
if (!cid) return null;
return {
rkey: uri.split('/').pop(),
cid,
name: value.name || 'Untitled',
size: part.size || 0,
mimeType: mime,
createdAt: value.createdAt || '',
};
}
async function loadVideos() {
// The query def sorts by createdAt; listRecords is the fallback when a
// deployment does not serve queries.
const entries = (records) =>
records.map((x) => entryOf(x.uri, x.value)).filter(Boolean);
try {
const r = await fetch(PDS + '/xrpc/dev.pdsjs.query.run?query=gallery-videos&limit=1000');
if (r.ok) { LIST = entries((await r.json()).records); return; }
} catch {}
const r = await fetch(PDS + '/xrpc/com.atproto.repo.listRecords?repo=' + DID + '&collection=' + FILES + '&limit=100');
LIST = r.ok
? entries((await r.json()).records)
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
: [];
}
const blobUrl = (cid) => '/.blobs/' + cid;
const pageUrl = (rkey) => location.origin + '/v/' + rkey;
const fmtBytes = (n) =>
n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
const fmtDate = (iso) =>
iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
const el = (tag, attrs, children) => {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === 'text') node.textContent = v;
else if (k === 'on') for (const [evt, fn] of Object.entries(v)) node.addEventListener(evt, fn);
else if (v !== null && v !== undefined) node.setAttribute(k, v);
}
for (const child of children || []) if (child) node.appendChild(child);
return node;
};
let toastTimer;
function toast(message) {
const node = document.getElementById('toast');
node.textContent = message;
node.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => node.classList.remove('on'), 2600);
}
async function copy(text, message) {
try { await navigator.clipboard.writeText(text); } catch {}
toast(message || 'Copied');
}
function go(path) {
history.pushState({}, '', path);
route();
}
const link = (path, attrs, children) =>
el('a', { href: path, ...attrs, on: { click: (e) => { e.preventDefault(); go(path); } } }, children);
function header() {
return el('header', { class: 'site' }, [
el('div', {}, [
link('/', {}, [el('h1', { text: 'Videos' })]),
el('div', { class: 'who', text: HANDLE }),
]),
]);
}
function footer() {
const node = el('footer');
node.append('Every video here is a file in ');
node.appendChild(el('a', { href: 'https://bsky.app/profile/' + HANDLE, text: HANDLE }));
node.append("'s atproto repo. Served by pds.js.");
return node;
}
function tile(entry) {
// preload=metadata draws the first frame as the poster without pulling
// the file; the ranged /.blobs route makes that a small read.
const clip = el('video', {
src: blobUrl(entry.cid),
preload: 'metadata',
muted: '',
playsinline: '',
});
return link('/v/' + entry.rkey, { class: 'tile' }, [
clip,
el('div', { class: 'label' }, [
el('div', { text: entry.name }),
el('div', { class: 'sub', text: fmtDate(entry.createdAt) + ' · ' + fmtBytes(entry.size) }),
]),
]);
}
function indexView() {
const app = document.getElementById('app');
app.textContent = '';
document.title = 'Videos';
app.appendChild(header());
if (LIST.length === 0) {
app.appendChild(el('p', { class: 'state', text: 'No videos yet. Drop one into Drive on the account page and it appears here.' }));
} else {
const grid = el('div', { class: 'grid' });
for (const entry of LIST) grid.appendChild(tile(entry));
app.appendChild(grid);
}
app.appendChild(footer());
}
function detailView(rkey) {
const app = document.getElementById('app');
app.textContent = '';
app.appendChild(header());
const entry = LIST.find((x) => x.rkey === rkey);
if (!entry) {
app.appendChild(el('p', { class: 'state', text: 'No video at this address.' }));
app.appendChild(el('nav', { class: 'back' }, [link('/', { text: '← All videos' })]));
app.appendChild(footer());
return;
}
document.title = entry.name + ' · Videos';
const player = el('video', {
src: blobUrl(entry.cid),
controls: '',
playsinline: '',
preload: 'metadata',
});
const detail = el('div', { class: 'detail' }, [
el('div', { class: 'frame' }, [player]),
el('h2', { text: entry.name }),
el('div', { class: 'meta' }, [
el('span', { text: fmtDate(entry.createdAt) }),
el('span', { text: fmtBytes(entry.size) }),
el('span', { text: entry.mimeType }),
]),
el('div', { class: 'acts' }, [
el('button', { class: 'btn', text: 'Copy link', on: { click: () => copy(pageUrl(entry.rkey), 'Link copied') } }),
el('button', { class: 'btn ghost', text: 'Copy video URL', on: { click: () => copy(location.origin + blobUrl(entry.cid), 'Video URL copied') } }),
el('button', {
class: 'btn ghost',
text: 'Copy blob ref',
on: {
click: () => copy(JSON.stringify({
$type: 'blob',
ref: { $link: entry.cid },
mimeType: entry.mimeType,
size: entry.size,
}), 'Blob ref copied'),
},
}),
]),
el('nav', { class: 'back' }, [link('/', { text: '← All videos' })]),
]);
app.appendChild(detail);
app.appendChild(footer());
}
function route() {
const match = location.pathname.match(/^\\/v\\/([^/]+)\\/?$/);
if (match) detailView(decodeURIComponent(match[1]));
else indexView();
}
window.addEventListener('popstate', route);
(async () => {
try {
await discover();
await loadVideos();
} catch (err) {
document.getElementById('app').textContent = '';
document.getElementById('app').appendChild(el('p', { class: 'state', text: 'Could not reach the PDS. ' + err.message }));
return;
}
route();
})();
</script>
</body>
</html>
`;
// ---- deploy ----
const upload = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'text/html; charset=utf-8' },
body: HTML,
});
const blob = (await upload.json()).blob;
await put(SITES, SITE, {
files: [
{
path: 'index.html',
blob,
contentType: 'text/html; charset=utf-8',
cacheControl: 'public, max-age=300',
},
],
fallback: 'index.html',
createdAt: new Date().toISOString(),
});
console.error(
`Deployed ${SITE}: ${Math.round(HTML.length / 1024)}KB, one file.`,
);
// ---- publish it as an installable app ----
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0e0e11"/>
<path d="M26 20l18 12-18 12z" fill="#5b8cff"/>
</svg>`;
const iconUp = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'image/svg+xml' },
body: ICON,
});
const icon = (await iconUp.json()).blob;
const deployed = await xrpc(
`com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`,
);
await put('dev.pdsjs.app.manifest', 'videos', {
name: 'Videos',
description:
'A video gallery from your Drive: drop a video into Drive on the account page and it plays here, streamed straight from your repo with seeking. The same blob can back a Bluesky post or a Spark video.',
icon,
site: { uri: deployed.uri, cid: deployed.cid },
suggestedSite: 'videos',
primitives: ['serve', 'query'],
external: false,
queries: [
{
name: 'gallery-videos',
collection: FILES,
sort: { field: 'createdAt', direction: 'desc' },
},
],
createdAt: new Date().toISOString(),
});
console.error('Published dev.pdsjs.app.manifest/videos.');