438 lines
14 KiB
JavaScript
438 lines
14 KiB
JavaScript
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 resolveAoConfig() {
|
|
const source = await fs.readFile(sourceConfigPath, "utf8");
|
|
const aoUrlMatch = source.match(/AO_URL\s*=\s*"([^"]+)"/);
|
|
const aoProcessIdMatch = source.match(/AO_PROCESS_ID\s*=\s*"([^"]+)"/);
|
|
return {
|
|
aoUrl: aoUrlMatch?.[1] || "",
|
|
aoProcessId: aoProcessIdMatch?.[1] || ""
|
|
};
|
|
}
|
|
|
|
const asObject = (value) => (typeof value === "object" && value !== null ? value : null);
|
|
const asString = (value) => (typeof value === "string" && value.length > 0 ? value : null);
|
|
|
|
function findManifestIdInTags(tagsValue) {
|
|
const tags = Array.isArray(tagsValue) ? tagsValue : [];
|
|
for (const tag of tags) {
|
|
const tagObject = asObject(tag);
|
|
if (!tagObject) continue;
|
|
const name = asString(tagObject.name) ?? asString(tagObject.Name);
|
|
const value = asString(tagObject.value) ?? asString(tagObject.Value);
|
|
if ((name === "LatestManifestId" || name === "ManifestId") && value) return value;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function extractManifestId(payload) {
|
|
const root = asObject(payload);
|
|
const results = asObject(root?.results);
|
|
if (!results) return "";
|
|
|
|
const outbox = asObject(results.outbox);
|
|
if (outbox) {
|
|
for (const message of Object.values(outbox)) {
|
|
const messageObject = asObject(message);
|
|
if (!messageObject) continue;
|
|
const direct = asString(messageObject.LatestManifestId) ?? asString(messageObject.ManifestId);
|
|
if (direct) return direct;
|
|
const tagged = findManifestIdInTags(messageObject.Tags);
|
|
if (tagged) return tagged;
|
|
}
|
|
}
|
|
|
|
const raw = asObject(results.raw);
|
|
const rawMessages = Array.isArray(raw?.Messages) ? raw.Messages : [];
|
|
for (const message of rawMessages) {
|
|
const messageObject = asObject(message);
|
|
if (!messageObject) continue;
|
|
const tagged = findManifestIdInTags(messageObject.Tags);
|
|
if (tagged) return tagged;
|
|
}
|
|
|
|
const json = asObject(results.json);
|
|
const body = json?.body;
|
|
const parsedBody = typeof body === "string" ? asObject(JSON.parse(body)) : asObject(body);
|
|
const jsonMessages = Array.isArray(parsedBody?.Messages) ? parsedBody.Messages : [];
|
|
for (const message of jsonMessages) {
|
|
const messageObject = asObject(message);
|
|
if (!messageObject) continue;
|
|
const tagged = findManifestIdInTags(messageObject.Tags);
|
|
if (tagged) return tagged;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
async function getLatestManifestFromAo() {
|
|
const { aoUrl, aoProcessId } = await resolveAoConfig();
|
|
if (!aoUrl || !aoProcessId) return "";
|
|
|
|
try {
|
|
const url = `${aoUrl}/${aoProcessId}~process@1.0/compute?Action=Get&require-codec=application/json&accept-bundle=true`;
|
|
const response = await fetch(url, { cache: "no-store" });
|
|
if (!response.ok) return "";
|
|
const payload = await response.json();
|
|
return extractManifestId(payload);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function resolvePostSlugs(gateway) {
|
|
const blogManifestTxId = (await getLatestManifestFromAo()) || (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" },
|
|
fallback: { id: indexTxId },
|
|
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);
|
|
});
|