#!/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).`, );