// Deploys site/ to Nexthub Pages: https://nextmix.iamnbutler.pages.nexthub.sh. The audio is copied // in from the renders (~/ace-demo/beats-songs/out) and never committed: a source object over 8 MiB // blocks deploys, and the repo only needs the app's definition. // // bun deploy.ts import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; const ROOT = "site"; const REPO = "https://api.nexthub.sh/repos/iamnbutler/nextmix"; const API = `${REPO}/applications/nextmix`; const TYPES: Record = { html: "text/html;charset=utf-8", m4a: "audio/mp4", png: "image/png" }; const token = execFileSync("git", ["credential-osxkeychain", "get"], { input: "protocol=https\nhost=nexthub.sh\n\n" }) .toString().match(/^password=(.*)$/m)?.[1]; if (!token) throw new Error("no nexthub.sh token in the Keychain"); const walk = (dir: string): string[] => readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walk(join(dir, n)) : [join(dir, n)])); // Nexthub rejects an unsorted manifest. const files = walk(ROOT).map((f) => relative(ROOT, f)).filter((p) => !p.endsWith(".DS_Store")).sort(); const bodies = new Map(files.map((p) => [p, readFileSync(join(ROOT, p))])); const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex"); const assets = files.map((path) => { const b = bodies.get(path)!; return { path, size: b.length, sha256: sha(b), content_type: TYPES[path.split(".").pop()!] ?? "application/octet-stream" }; }); const manifest = { version: 1, kind: "pages", assets, modules: [], routes: { headers: [{ path: "/audio/*", values: [{ name: "cache-control", value: "public, max-age=86400" }] }], redirects: [] }, }; const auth = { authorization: `Bearer ${token}` }; async function call(url: string, init: RequestInit) { for (let attempt = 0; ; attempt++) { const r = await fetch(url, init); if ((r.status === 429 || r.status === 503 || r.status === 422) && attempt < 5) { await Bun.sleep(1000 * 2 ** attempt); continue; } const text = await r.text(); if (!r.ok) throw new Error(`${init.method} ${url}: ${r.status} ${text.slice(0, 400)}`); return { status: r.status, body: text ? JSON.parse(text) : {} }; } } let res = await call(`${API}/deployments?target=production`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ manifest }), }); const byHash = new Map(assets.map((a) => [a.sha256, a.path])); while (res.status === 202) { const id = res.body.id ?? res.body.deployment?.id; // At most 1,000 files and 64 MiB a request. let form = new FormData(), n = 0, bytes = 0; for (const h of res.body.missing ?? []) { const b = bodies.get(byHash.get(h)!)!; if (n && (n === 1000 || bytes + b.length > 60 * 2 ** 20)) break; form.append(h, new Blob([b]), h); n++; bytes += b.length; } res = await call(`${REPO}/deployments/${id}/files`, { method: "POST", headers: auth, body: form }); } console.log(JSON.stringify(res.body.urls ?? res.body, null, 1).slice(0, 600));