init
This commit is contained in:
47
scripts/deploy-up.mjs
Normal file
47
scripts/deploy-up.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
function parseArg(name) {
|
||||
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`));
|
||||
if (!raw) return "";
|
||||
return raw.slice(name.length + 3).trim();
|
||||
}
|
||||
|
||||
function runNodeScript(scriptPath, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||
cwd: root,
|
||||
stdio: "inherit"
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Deploy failed with exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const walletArg = parseArg("wallet");
|
||||
const gatewayArg = parseArg("gateway");
|
||||
const appName = parseArg("app-name");
|
||||
const appVersion = parseArg("app-version");
|
||||
|
||||
await runNodeScript(path.join(root, "scripts", "deploy.mjs"), [
|
||||
"--upload-mode=up",
|
||||
...(walletArg ? [`--wallet=${walletArg}`] : []),
|
||||
...(gatewayArg ? [`--gateway=${gatewayArg}`] : []),
|
||||
...(appName ? [`--app-name=${appName}`] : []),
|
||||
...(appVersion ? [`--app-version=${appVersion}`] : [])
|
||||
]);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
356
scripts/deploy.mjs
Normal file
356
scripts/deploy.mjs
Normal file
@@ -0,0 +1,356 @@
|
||||
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);
|
||||
});
|
||||
25
scripts/generate-wallet.mjs
Normal file
25
scripts/generate-wallet.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import Arweave from "arweave";
|
||||
|
||||
const root = process.cwd();
|
||||
const walletPath = path.join(root, "wallet.json");
|
||||
|
||||
async function main() {
|
||||
const arweave = Arweave.init({
|
||||
host: "arweave.net",
|
||||
port: 443,
|
||||
protocol: "https",
|
||||
timeout: 30_000,
|
||||
logging: false
|
||||
});
|
||||
|
||||
const jwk = await arweave.wallets.generate();
|
||||
await fs.writeFile(walletPath, `${JSON.stringify(jwk, null, 2)}\n`, "utf8");
|
||||
console.log(`Generated wallet: ${walletPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
100
scripts/ship.mjs
Normal file
100
scripts/ship.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const root = process.cwd();
|
||||
const nodeModulesPath = path.join(root, "node_modules");
|
||||
const walletPath = path.join(root, "wallet.json");
|
||||
|
||||
async function pathExists(targetPath) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: root,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stdout += text;
|
||||
process.stdout.write(text);
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
process.stderr.write(text);
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractLink(output, label) {
|
||||
const regex = new RegExp(`${label}:\\s*(https?:\\/\\/\\S+)`);
|
||||
const match = output.match(regex);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
async function ensureDependencies() {
|
||||
if (await pathExists(nodeModulesPath)) {
|
||||
console.log("Dependencies present: skipping npm install.");
|
||||
return;
|
||||
}
|
||||
console.log("Dependencies missing: running npm install...");
|
||||
await runCommand("npm", ["install"]);
|
||||
}
|
||||
|
||||
async function ensureWallet() {
|
||||
if (await pathExists(walletPath)) {
|
||||
console.log("wallet.json present: skipping wallet:new.");
|
||||
return;
|
||||
}
|
||||
console.log("wallet.json missing: running npm run wallet:new...");
|
||||
await runCommand("npm", ["run", "wallet:new"]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureDependencies();
|
||||
await ensureWallet();
|
||||
|
||||
console.log("Running deploy pipeline: npm run deploy:up");
|
||||
const deployResult = await runCommand("npm", ["run", "deploy:up"]);
|
||||
|
||||
const appUrl = extractLink(deployResult.stdout, "App URL");
|
||||
const codeArchiveUrl = extractLink(deployResult.stdout, "Code Archive URL");
|
||||
|
||||
if (!appUrl || !codeArchiveUrl) {
|
||||
throw new Error(
|
||||
"Deploy step did not emit required links (App URL and Code Archive URL)."
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Ship completed.");
|
||||
console.log(`App URL: ${appUrl}`);
|
||||
console.log(`Code Archive URL: ${codeArchiveUrl}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user