import fs from "node:fs/promises"; import path from "node:path"; import { gzipSync } from "node:zlib"; import { spawn } from "node:child_process"; import Arweave from "arweave"; import mime from "mime-types"; import arbundles from "warp-arbundles"; const { createData, ArweaveSigner } = arbundles; const root = process.cwd(); const distDir = path.join(root, "dist"); const sourceConfigPath = path.join(root, "src", "config.ts"); const DEFAULT_GATEWAY = "https://arweave.net"; const DEFAULT_UPLOAD_MODE = "arweave"; const UP_UPLOAD_MODE = "up"; const SUPPORTED_UPLOAD_MODES = new Set([DEFAULT_UPLOAD_MODE, UP_UPLOAD_MODE]); const DEFAULT_UP_UPLOAD_SERVICE = "https://up.arweave.net"; function parseArg(name) { const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`)); if (!raw) return ""; return raw.slice(name.length + 3).trim(); } function buildGatewayConfig(gateway) { const parsed = new URL(gateway); return { host: parsed.hostname, port: parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "https:" ? 443 : 80, protocol: parsed.protocol.replace(":", "") }; } async function pathExists(targetPath) { try { await fs.access(targetPath); return true; } catch { return false; } } async function listFiles(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const nested = await Promise.all( entries.map(async (entry) => { const absolutePath = path.join(dir, entry.name); if (entry.isDirectory()) return listFiles(absolutePath); return [absolutePath]; }) ); return nested.flat(); } function runBinary(command, args, cwd) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); const stdoutChunks = []; let stderr = ""; child.stdout.on("data", (chunk) => stdoutChunks.push(chunk)); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", reject); child.on("close", (code) => { if (code !== 0) { reject(new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`)); return; } resolve({ stdout: Buffer.concat(stdoutChunks), stderr }); }); }); } async function createCodeArchiveBuffer() { const { stdout } = await runBinary( "tar", ["--exclude=./node_modules", "--exclude=./wallet.json", "-cf", "-", "."], root ); return gzipSync(stdout); } async function loadWallet(arweave, walletArg) { const envWallet = process.env.ARWEAVE_WALLET?.trim(); if (walletArg) { const absoluteWalletPath = path.resolve(root, walletArg); if (!(await pathExists(absoluteWalletPath))) { throw new Error(`Wallet file not found: ${absoluteWalletPath}`); } const jwkRaw = await fs.readFile(absoluteWalletPath, "utf8"); return { walletPath: absoluteWalletPath, generated: false, jwk: JSON.parse(jwkRaw) }; } if (envWallet) { const absoluteWalletPath = path.resolve(root, envWallet); if (!(await pathExists(absoluteWalletPath))) { throw new Error(`ARWEAVE_WALLET points to a missing file: ${absoluteWalletPath}`); } const jwkRaw = await fs.readFile(absoluteWalletPath, "utf8"); return { walletPath: absoluteWalletPath, generated: false, jwk: JSON.parse(jwkRaw) }; } const defaultWalletPath = path.join(root, "wallet.json"); if (!(await pathExists(defaultWalletPath))) { const jwk = await arweave.wallets.generate(); await fs.writeFile(defaultWalletPath, `${JSON.stringify(jwk, null, 2)}\n`, "utf8"); return { walletPath: defaultWalletPath, generated: true, jwk }; } const jwkRaw = await fs.readFile(defaultWalletPath, "utf8"); return { walletPath: defaultWalletPath, generated: false, jwk: JSON.parse(jwkRaw) }; } async function uploadTransaction(arweave, jwk, data, tags) { const tx = await arweave.createTransaction({ data }, jwk); for (const [key, value] of tags) tx.addTag(key, value); await arweave.transactions.sign(tx, jwk); const uploader = await arweave.transactions.getUploader(tx); while (!uploader.isComplete) await uploader.uploadChunk(); return tx.id; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function isRetriableUploadError(message) { return ( message.includes("timeout") || message.includes("504") || message.includes("502") || message.includes("503") || message.includes("429") || message.includes("EAI_AGAIN") || message.includes("ECONNRESET") || message.includes("failed to fetch") ); } async function uploadWithUpService(serviceUrl, signer, data, tags) { const payload = Buffer.isBuffer(data) ? data : Buffer.from(data); const dataItem = createData(payload, signer, { tags: tags.map(([name, value]) => ({ name, value })) }); await dataItem.sign(signer); const txId = await dataItem.id; for (let attempt = 1; attempt <= 3; attempt += 1) { try { const response = await fetch(`${serviceUrl.replace(/\/+$/, "")}/tx`, { method: "POST", headers: { "content-type": "application/octet-stream" }, body: dataItem.getRaw() }); const bodyText = await response.text(); if (!response.ok) { throw new Error( `up upload failed (${response.status}${bodyText ? `): ${bodyText.slice(0, 220)}` : ")"}` ); } let responseTxId = ""; try { const bodyJson = JSON.parse(bodyText); if (typeof bodyJson?.id === "string") responseTxId = bodyJson.id.trim(); } catch { responseTxId = bodyText.trim(); } return responseTxId || txId; } catch (error) { const message = error?.message || String(error); if (!isRetriableUploadError(message) || attempt === 3) throw error; await sleep(1500 * attempt); } } throw new Error("up upload failed unexpectedly."); } function resolveUploadMode(uploadModeArg) { const mode = (uploadModeArg || process.env.ARWEAVE_UPLOAD_MODE || DEFAULT_UPLOAD_MODE).trim(); if (!SUPPORTED_UPLOAD_MODES.has(mode)) { throw new Error( `Unsupported upload mode "${mode}". Supported values: ${[...SUPPORTED_UPLOAD_MODES].join(", ")}` ); } return mode; } async function createUploader(uploadMode, arweave, jwk) { if (uploadMode === DEFAULT_UPLOAD_MODE) { return { mode: DEFAULT_UPLOAD_MODE, serviceUrl: null, async beforeUpload() {}, upload(data, tags) { return uploadTransaction(arweave, jwk, data, tags); } }; } const signer = new ArweaveSigner(jwk); return { mode: UP_UPLOAD_MODE, serviceUrl: DEFAULT_UP_UPLOAD_SERVICE, async beforeUpload() {}, upload(data, tags) { return uploadWithUpService(DEFAULT_UP_UPLOAD_SERVICE, signer, data, tags); } }; } async function resolveBlogManifestTxId() { const source = await fs.readFile(sourceConfigPath, "utf8"); const match = source.match(/MANIFEST_TX_ID\s*=\s*"([^"]+)"/); return match?.[1] || ""; } async function resolvePostSlugs(gateway) { const blogManifestTxId = await resolveBlogManifestTxId(); if (!blogManifestTxId) return []; try { const response = await fetch(`${gateway.replace(/\/+$/, "")}/${blogManifestTxId}`); if (!response.ok) return []; const payload = await response.json(); if (!Array.isArray(payload?.posts)) return []; return payload.posts .map((post) => (typeof post?.slug === "string" ? post.slug.trim() : "")) .filter((slug) => slug.length > 0); } catch { return []; } } async function main() { const walletArg = parseArg("wallet"); const gatewayArg = parseArg("gateway"); const uploadModeArg = parseArg("upload-mode"); const appName = parseArg("app-name") || "hyperzine"; const appVersion = parseArg("app-version") || "1.0.0"; const uploadMode = resolveUploadMode(uploadModeArg); const gateway = gatewayArg || process.env.ARWEAVE_GATEWAY || DEFAULT_GATEWAY; if (!(await pathExists(distDir))) { throw new Error("dist/ is missing. Run npm run build first."); } const arweave = Arweave.init({ ...buildGatewayConfig(gateway), timeout: 30_000, logging: false }); const { walletPath, generated, jwk } = await loadWallet(arweave, walletArg); console.log(`${generated ? "Generated" : "Using"} wallet: ${walletPath}`); console.log(`Gateway: ${gateway}`); console.log(`Upload mode: ${uploadMode}`); const uploader = await createUploader(uploadMode, arweave, jwk); if (uploader.serviceUrl) console.log(`Upload service: ${uploader.serviceUrl}`); await uploader.beforeUpload(); const filePaths = await listFiles(distDir); if (!filePaths.length) { throw new Error("dist/ is empty. Run npm run build first."); } const codeArchiveData = await createCodeArchiveBuffer(); const codeArchiveId = await uploader.upload(codeArchiveData, [ ["Content-Type", "application/gzip"], ["Content-Encoding", "gzip"], ["App-Name", appName], ["App-Version", appVersion], ["Type", "code-archive"] ]); console.log(`Uploaded code archive: ${codeArchiveId}`); const pathMap = {}; let indexTxId = ""; for (const absolutePath of filePaths) { const relativePath = path.relative(distDir, absolutePath).replace(/\\/g, "/"); const contentType = mime.lookup(relativePath) || "application/octet-stream"; const data = await fs.readFile(absolutePath); const txId = await uploader.upload(data, [ ["Content-Type", String(contentType)], ["App-Name", appName], ["App-Version", appVersion], ["Type", "app-asset"], ["File-Path", relativePath], ["code", codeArchiveId] ]); pathMap[relativePath] = { id: txId }; if (relativePath === "index.html") indexTxId = txId; console.log(`Uploaded ${relativePath}: ${txId}`); } if (!indexTxId) { throw new Error("Could not find uploaded index.html. Dist output is invalid for SPA deploy."); } const slugs = await resolvePostSlugs(gateway); for (const slug of slugs) { pathMap[slug] = { id: indexTxId }; pathMap[`${slug}/`] = { id: indexTxId }; } const manifest = { manifest: "arweave/paths", version: "0.2.0", index: { path: "index.html" }, paths: pathMap }; const manifestId = await uploader.upload(JSON.stringify(manifest), [ ["Content-Type", "application/x.arweave-manifest+json"], ["App-Name", appName], ["App-Version", appVersion], ["Type", "manifest"], ["code", codeArchiveId] ]); const appUrl = `${gateway.replace(/\/+$/, "")}/${manifestId}/`; const codeArchiveUrl = `${gateway.replace(/\/+$/, "")}/${codeArchiveId}`; console.log(""); console.log(`Code Archive ID: ${codeArchiveId}`); console.log(`Code Archive URL: ${codeArchiveUrl}`); console.log(`Manifest ID (AppArweaveID): ${manifestId}`); console.log(`App URL: ${appUrl}`); if (slugs.length) { console.log(`Slug route aliases: ${slugs.join(", ")}`); } else { console.log("Slug route aliases: none generated."); } } main().catch((error) => { console.error(error?.message || String(error)); process.exit(1); });