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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
149 lines · 4.6 KB Raw History
#!/usr/bin/env node

// Publish the built wizard as a site on a PDS. The page, its assets and a
// config.json become files in a dev.pdsjs.site.deploy record; the site's
// fallback entry serves the page for /callback and /update, the app's two
// client-side routes.
//
// Build first: npm run build (in packages/start).
//
// Environment:
//   PDS_URL           e.g. https://pds.pdsjs.dev
//   PDS_IDENTIFIER    handle or DID of the publishing account
//   PDS_PASSWORD      account password or an app password
//   SITE_NAME         record key and subdomain (default: start)
//   OAUTH_CLIENT_ID   the wizard's OAuth client id
//   OAUTH_SCOPES      space-separated scope names
//   ARTIFACT_URL      optional, default https://artifacts.pdsjs.dev/latest
//   CF_API_URL        optional, default /cf-api (the PDS's passthrough)

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distDir = join(appDir, 'dist');
const siteName = process.env.SITE_NAME ?? 'start';
const pdsUrl = process.env.PDS_URL;
const identifier = process.env.PDS_IDENTIFIER;
const password = process.env.PDS_PASSWORD;
const clientId = process.env.OAUTH_CLIENT_ID;
const scopes = process.env.OAUTH_SCOPES;
if (!pdsUrl || !identifier || !password || !clientId || !scopes) {
  console.error(
    'Set PDS_URL, PDS_IDENTIFIER, PDS_PASSWORD, OAUTH_CLIENT_ID and OAUTH_SCOPES.',
  );
  process.exit(1);
}

const COLLECTION = 'dev.pdsjs.site.deploy';

/** Content-Types by extension, for what a vite build emits. */
const TYPES = new Map([
  ['html', 'text/html; charset=utf-8'],
  ['css', 'text/css; charset=utf-8'],
  ['js', 'text/javascript; charset=utf-8'],
  ['json', 'application/json'],
  ['svg', 'image/svg+xml'],
  ['png', 'image/png'],
  ['ico', 'image/x-icon'],
  ['txt', 'text/plain; charset=utf-8'],
  ['woff2', 'font/woff2'],
]);

/** @param {string} path */
function contentType(path) {
  const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
  return TYPES.get(ext) ?? 'application/octet-stream';
}

/** @param {string} dir @returns {string[]} */
function walk(dir) {
  return readdirSync(dir).flatMap((name) => {
    const full = join(dir, name);
    return statSync(full).isDirectory() ? walk(full) : [full];
  });
}

/** @param {string} path @param {RequestInit} init */
async function xrpc(path, init = {}) {
  const response = await fetch(`${pdsUrl}/xrpc/${path}`, init);
  const body = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new Error(
      `${path}: ${response.status} ${body.message ?? body.error ?? ''}`,
    );
  }
  return body;
}

const session = await xrpc('com.atproto.server.createSession', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ identifier, password }),
});
const auth = { Authorization: `Bearer ${session.accessJwt}` };

/** @param {Uint8Array} bytes @param {string} type */
async function uploadBlob(bytes, type) {
  const { blob } = await xrpc('com.atproto.repo.uploadBlob', {
    method: 'POST',
    headers: { ...auth, 'Content-Type': type },
    body: bytes,
  });
  return blob;
}

const IMMUTABLE = 'public, max-age=31536000, immutable';
const SHORT = 'public, max-age=300';

const files = [];
for (const full of walk(distDir)) {
  const path = relative(distDir, full);
  const blob = await uploadBlob(readFileSync(full), contentType(path));
  files.push({
    path,
    blob,
    contentType: contentType(path),
    // Vite hashes everything under assets/; the rest moves between deploys.
    cacheControl: path.startsWith('assets/') ? IMMUTABLE : SHORT,
  });
}

const config = {
  clientId,
  scopes,
  cfApiUrl: process.env.CF_API_URL ?? '/cf-api',
  artifactUrl: process.env.ARTIFACT_URL ?? 'https://artifacts.pdsjs.dev/latest',
};
const configBlob = await uploadBlob(
  new TextEncoder().encode(`${JSON.stringify(config, null, 2)}\n`),
  'application/json',
);
files.push({
  path: 'config.json',
  blob: configBlob,
  contentType: 'application/json',
  cacheControl: SHORT,
});

await xrpc('com.atproto.repo.putRecord', {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    repo: session.did,
    collection: COLLECTION,
    rkey: siteName,
    record: {
      $type: COLLECTION,
      files,
      // /callback and /update are client-side routes; the page answers them.
      fallback: 'index.html',
      createdAt: new Date().toISOString(),
    },
  }),
});

console.log(
  `Published the wizard to the "${siteName}" site (${files.length} files).`,
);