Something went wrong. Try again.
Content-addressed version deploy system for Diffuse elements.diffuse.sh
Something went wrong. Try again.
12 kB · 421 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422// ipfs add artifacts/ --recursive --cid-version=1 --hidden --max-directory-links=1000 --chunker=size-1048576
import * as Fs from "@std/fs";import * as Path from "@std/path";import { cpSync } from "node:fs";import { load } from "@std/dotenv";
import { MemoryBlockstore } from "blockstore-core/memory";import { importer } from "ipfs-unixfs-importer";import { CarWriter } from "@ipld/car";import { CID } from "multiformats/cid";import * as DagPB from "@ipld/dag-pb";
import artifactsJson from "../../artifacts/artifacts.json" with { type: "json",};
import type { Artifact } from "../../common/types.d.ts";import { execSync } from "../../common/index.ts";import { createCAR } from "../../common/car.ts";
const artifacts: Record<string, Artifact> = artifactsJson as any;
////////////////////////////////////////////// VERIFY IPFS DAEMON IS RUNNING////////////////////////////////////////////
// Fail fast if the local IPFS node isn't up — everything downstream (dag// import/put, Filebase pinning, IPNS publish) depends on it, and a missing// daemon would otherwise surface as an obscure error or a pinning poll that// times out after 10 minutes. We hit the HTTP API directly (the same thing// the `ipfs` CLI does under the hood) rather than spawning a subprocess.const IPFS_API = "http://127.0.0.1:5001";
let daemonUp = false;try { const res = await fetch(`${IPFS_API}/api/v0/version`, { method: "POST" }); daemonUp = res.ok;} catch { daemonUp = false;}
if (!daemonUp) { console.error("❌ IPFS daemon is not running."); console.error(" Start it with: ipfs daemon"); Deno.exit(1);}
// Also verify the node is connected to the network — Filebase fetches the// CID over bitswap, so an isolated daemon (0 peers) would cause the pin poll// to time out after 10 minutes.let peerCount = 0;try { const peersRes = await fetch(`${IPFS_API}/api/v0/swarm/peers`, { method: "POST", }); if (peersRes.ok) { const peersResult: { Peers?: unknown[] } = await peersRes.json(); peerCount = peersResult.Peers?.length ?? 0; }} catch { // Treat as 0 peers — will fail the check below.}
if (peerCount === 0) { console.error("❌ IPFS daemon has no network peers."); console.error( " Filebase can't fetch the CID over bitswap — check your connectivity.", ); Deno.exit(1);}
console.log(`✅️ IPFS daemon is running (${peerCount} peers)`);
////////////////////////////////////////////// ADD CARS TO IPFS////////////////////////////////////////////
Fs.ensureDirSync("cars");
for (const artifact of Object.values(artifacts)) { const carPath = Path.join("cars", artifact.cid + ".car"); const exists = Fs.existsSync(carPath);
if (!exists) { const inputPath = Path.join("artifacts", artifact.cid); const tmpDir = Deno.makeTempDirSync();
cpSync(inputPath, tmpDir, { dereference: true, preserveTimestamps: true, recursive: true, });
await createCAR(tmpDir, carPath);
Deno.removeSync(tmpDir, { recursive: true }); }
execSync( "ipfs", "dag", "import", carPath, );}
////////////////////////////////////////////// GENERATE ROOT DAG & ADD BLOCKS TO IPFS////////////////////////////////////////////
const blockstore = new MemoryBlockstore();
const source = [ "artifacts/_redirects", "artifacts/artifacts.json", "artifacts/index.html", "artifacts/versions.json",].map( (path) => { return { path: path.split("/")[1], content: Deno.readFileSync(path), }; },);
// Walk dist/ recursively (including subdirectories like chronicle/, fonts/,// images/, styles/) and sort for deterministic directory CIDs.const distFiles = [];for (const entry of Fs.walkSync("dist")) { if (!entry.isFile) continue;
distFiles.push({ path: entry.path.split("/").slice(1).join("/"), content: Deno.readFileSync(entry.path), });}distFiles.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));source.push(...distFiles);
const links = [];const importerRoots: CID[] = [];
for await (const entry of importer(source, blockstore)) { // Only add top-level entries (path has no "/") as root DAG links. // Nested files are reachable through their parent directory link. if (!entry.path?.includes("/")) { links.push( DagPB.createLink(entry.path ?? "", Number(entry.size), entry.cid), ); }
importerRoots.push(entry.cid);}
{ const { writer: carWriter, out } = CarWriter.create(importerRoots); const importCmd = new Deno.Command("ipfs", { args: ["dag", "import", "--silent"], stdin: "piped", });
const importProc = importCmd.spawn(); const pipePromise = ReadableStream.from(out).pipeTo(importProc.stdin);
for await (const { cid, bytes: bytesGen } of blockstore.getAll()) { const chunks: Uint8Array[] = []; for await (const chunk of bytesGen) chunks.push(chunk); const size = chunks.reduce((n, c) => n + c.length, 0); const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } await carWriter.put({ cid, bytes }); }
await carWriter.close(); await pipePromise; await importProc.output();}
Object.values(artifacts).forEach((artifact) => { links.push( DagPB.createLink(artifact.cid, 0, CID.parse(artifact.cid)), );});
const bytes = DagPB.encode({ Data: new Uint8Array([8, 1]), Links: links.toSorted((a, b) => { const aName = a.Name ?? ""; const bName = b.Name ?? "";
if (aName < bName) return -1; if (aName > bName) return 1; return 0; }),});
////////////////////////////////////////////// ADD ROOT DAG TO IPFS////////////////////////////////////////////
const cmd = new Deno.Command("ipfs", { args: ["dag", "put", "--store-codec", "dag-pb", "--input-codec", "dag-pb"], stdin: "piped", stdout: "piped",});
const process = cmd.spawn();const writer = process.stdin.getWriter();
await writer.write(bytes);await writer.close();
const cid = (await process.stdout.text()).trim();
console.log("✅️ Added artifacts DAG to IPFS with CID: " + cid);
////////////////////////////////////////////// SET DNSLINK////////////////////////////////////////////
const env = await load();
const deleteCmd = new Deno.Command("lexicon", { args: [ "dnsimple", "delete", "diffuse.sh", "TXT", "--name", "_dnslink.elements.diffuse.sh", ], env,});
await deleteCmd.spawn().output();
const createCmd = new Deno.Command("lexicon", { args: [ "dnsimple", "create", "diffuse.sh", "TXT", "--name", "_dnslink.elements.diffuse.sh", "--content", `"dnslink=/ipfs/${cid}"`, ], env,});
await createCmd.spawn().output();
console.log("✅️ DNSLink set");
////////////////////////////////////////////// PIN TO FILEBASE////////////////////////////////////////////
// Filebase's Pinning Service API and RPC API use a per-bucket access token// generated from the Console (Access Keys → IPFS RPC API endpoint →// Choose Bucket to Generate Token). This token is scoped to the `diffuse-sh`// bucket and is sent verbatim as the Bearer token — no encoding needed.const filebaseToken = env.FILEBASE_TOKEN;
// Human-readable bucket object name: `diffuse-deploy-<ISO timestamp>`.// Filebase requires unique names per bucket (409 otherwise), and the `name`// field is capped at 255 chars — well within range.const timestamp = new Date().toISOString().replace(/[:.]/g, "-");const objectName = `diffuse-deploy-${timestamp}`;
const pinResponse = await fetch("https://api.filebase.io/v1/ipfs/pins", { method: "POST", headers: { "Authorization": `Bearer ${filebaseToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ cid, name: objectName, meta: { project: "diffuse-sh" }, }),});
if (!pinResponse.ok) { console.error( `❌ Filebase pin failed: ${pinResponse.status} ${pinResponse.statusText}`, ); console.error(await pinResponse.text()); Deno.exit(1);}
const pinResult = await pinResponse.json();console.log( `✅️ Pin requested on Filebase bucket 'diffuse-sh' as '${objectName}' (requestid: ${pinResult.requestid}, status: ${pinResult.status})`,);
// The Pinning Service API is asynchronous: the POST above returns immediately// with status `queued` (or `pinning`). Publishing the IPNS record before the// pin completes means resolvers could fetch a CID that Filebase's node can't// yet serve, so we poll GET /pins/{requestid} until it reaches `pinned`.const requestId = pinResult.requestid ?? pinResult.requestId;if (!requestId) { console.warn( "⚠️ Pin response had no requestid; cannot poll for completion. Proceeding anyway.", );} else { const maxAttempts = 120; // 120 × 5s = 10 min ceiling const pollIntervalMs = 5000; let pinned = false; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const statusResponse = await fetch( `https://api.filebase.io/v1/ipfs/pins/${requestId}`, { headers: { "Authorization": `Bearer ${filebaseToken}` } }, ); if (!statusResponse.ok) { console.warn( `⚠️ Pin status poll failed: ${statusResponse.status} ${statusResponse.statusText}`, ); break; } const statusResult = await statusResponse.json(); const status = statusResult.status; if (status === "pinned") { pinned = true; console.log(`✅️ Pin complete (status: pinned)`); break; } if (status === "failed") { console.error(`❌ Filebase pin failed (status: failed)`); console.error(JSON.stringify(statusResult, null, 2)); Deno.exit(1); } console.log( ` pin status: ${status} (attempt ${attempt}/${maxAttempts})`, ); await new Promise((r) => setTimeout(r, pollIntervalMs)); } if (!pinned) { console.error( `❌ Pin did not reach 'pinned' status within ${ maxAttempts * pollIntervalMs / 1000 }s`, ); Deno.exit(1); }}
////////////////////////////////////////////// UPDATE FILEBASE SITE 'diffuse' (IPNS PUBLISH)////////////////////////////////////////////
const siteKey = "diffuse.myfilebase.site";
// 1. Verify the IPNS key exists before publishing.// Filebase sites are backed by an IPNS keypair; if the key name doesn't// match the site name, the publish would silently target the wrong key// (or the node's default 'self' key).const keyListResponse = await fetch( "https://rpc.filebase.io/api/v0/key/list", { method: "POST", headers: { "Authorization": `Bearer ${filebaseToken}` }, },);
if (!keyListResponse.ok) { console.error( `❌ Filebase key/list failed: ${keyListResponse.status} ${keyListResponse.statusText}`, ); console.error(await keyListResponse.text()); Deno.exit(1);}
const keyListResult = await keyListResponse.json();const keys = keyListResult.Keys ?? keyListResult.keys ?? [];const siteKeyExists = keys.some( (k: any) => (k.Name ?? k.name) === siteKey,);
if (!siteKeyExists) { console.error( `❌ IPNS key '${siteKey}' not found. Available keys: ${ keys.map((k: any) => k.Name ?? k.name).join(", ") || "(none)" }`, ); console.error( ` Generate it in the Filebase Console (Sites → diffuse → manage keys) or via /api/v0/key/gen`, ); Deno.exit(1);}
// 2. Publish the CID to IPNS using the site's key.// ttl=1m makes resolvers re-check sooner (default 5m), trading a small// resolution overhead for faster propagation after deploys.const publishResponse = await fetch( `https://rpc.filebase.io/api/v0/name/publish?arg=/ipfs/${cid}&key=${siteKey}&ttl=1m`, { method: "POST", headers: { "Authorization": `Bearer ${filebaseToken}` }, },);
if (!publishResponse.ok) { console.error( `❌ Filebase IPNS publish failed: ${publishResponse.status} ${publishResponse.statusText}`, ); console.error(await publishResponse.text()); Deno.exit(1);}
const publishResult = await publishResponse.json();const ipnsName = publishResult.Name ?? publishResult.name;console.log( `✅️ Published CID to IPNS key '${siteKey}' (${ipnsName ?? "published"})`,);