import type { S3Client } from "bun"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { client, getText, putBytes } from "./s3.ts"; import { need } from "../site/lib/env.ts"; /* WHERE */ const SOURCE = "carlomitchener/carlomitchener"; const HEAD_KEY = "data/build/head"; const AGENT = "carlomitchener-site"; const SRC_DIR = "/tmp/src"; const CACHE_DIR = process.env.BUN_INSTALL_CACHE_DIR || "/tmp/bun/cache"; const DRY = process.env.DRY === "1"; const HOLD = process.env.DRY_DIR ?? "/tmp/carlomitchener"; const SRC = process.env.SRC ?? ""; /* CLOCK */ let mark = Date.now(); function log(line: string) { const now = Date.now(); console.log(`${line} ${now - mark}ms`); mark = now; } /* HEAD */ type Head = { sha: string; etag: string; shop: string }; type Source = "push" | "schedule" | "automator" | "manual"; type Event = { source?: Source; sha?: string }; const short = (sha: string) => sha.slice(0, 7) || "none"; const local = () => join(HOLD, "head"); async function readHead(s3: S3Client | null): Promise { const text = s3 ? await getText(s3, HEAD_KEY) : existsSync(local()) ? readFileSync(local(), "utf8") : null; const [sha = "", etag = "", shop = ""] = (text ?? "").trim().split("\n"); return { sha: sha.trim(), etag: etag.trim(), shop: shop.trim() }; } async function writeHead(s3: S3Client | null, head: Head): Promise { const body = `${head.sha}\n${head.etag}\n${head.shop}\n`; if (!s3) { mkdirSync(HOLD, { recursive: true }); writeFileSync(local(), body); return; } await putBytes(s3, HEAD_KEY, body, { type: "text/plain", cacheControl: "no-store" }); } /* GITHUB */ async function commit(etag: string): Promise<{ sha: string; etag: string } | null> { const headers: Record = { "user-agent": AGENT, accept: "application/vnd.github+json" }; if (etag) headers["if-none-match"] = etag; const res = await fetch(`https://api.github.com/repos/${SOURCE}/commits/main`, { headers }); if (res.status === 304) return null; if (!res.ok) throw new Error(`github ${res.status}: ${(await res.text()).slice(0, 200)}`); const body = (await res.json()) as { sha?: string }; if (!body.sha) throw new Error("github: the commit carries no sha"); return { sha: body.sha, etag: res.headers.get("etag") ?? "" }; } async function unpack(slug: string, ref: string, into: string): Promise { const stage = `${into}.stage`; rmSync(stage, { recursive: true, force: true }); rmSync(into, { recursive: true, force: true }); const res = await fetch(`https://codeload.github.com/${slug}/tar.gz/${ref}`, { headers: { "user-agent": AGENT } }); if (!res.ok) throw new Error(`codeload ${slug} ${ref}: ${res.status}`); const files = await new Bun.Archive(new Uint8Array(await res.arrayBuffer())).extract(stage); const [top] = readdirSync(stage); if (!files || !top) throw new Error(`codeload ${slug} ${ref}: the tarball is empty`); renameSync(join(stage, top), into); rmSync(stage, { recursive: true, force: true }); } /* CHILD */ function childEnv(): Record { const env = { ...(process.env as Record), HOME: "/tmp", BUN_INSTALL_CACHE_DIR: CACHE_DIR }; if (env.KIT) env.KIT = resolve(env.KIT); return env; } async function run(cmd: string[], cwd: string): Promise { const child = Bun.spawn(cmd, { cwd, env: childEnv(), stdout: "pipe", stderr: "pipe" }); const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); const code = await child.exited; if (code !== 0) throw new Error(`${cmd.join(" ")}: exit ${code}\n${(err || out).trim().slice(-1500)}`); return out; } /* SNAPSHOT */ function shopHash(site: string): string { const path = join(site, "data", "shop.json"); if (!existsSync(path)) return ""; const snapshot = JSON.parse(readFileSync(path, "utf8")) as { products?: unknown }; return createHash("sha256").update(JSON.stringify(snapshot.products ?? [])).digest("hex").slice(0, 16); } /* BUILD */ async function make(s3: S3Client | null, head: Head, stored: Head, same: boolean): Promise { const bun = process.execPath; if (SRC) log(`source ${SRC}`); else { await unpack(SOURCE, head.sha, SRC_DIR); log(`source ${short(head.sha)}`); } const site = join(SRC || SRC_DIR, "site"); await run([bun, "scripts/kit.ts", "--hold"], site); log(`kit ${readFileSync(join(site, "kit.lock"), "utf8").trim().slice(0, 7)}`); await run([bun, "install", "--frozen-lockfile"], site); log("install"); await run([bun, "run", DRY ? "fake" : "snapshot"], site); head.shop = shopHash(site); log(`snapshot ${head.shop || "none"}`); if (same && head.shop === stored.shop) { if (head.etag !== stored.etag) await writeHead(s3, head); return `unchanged ${short(head.sha)}, products same`; } const out = await run([bun, "run", "push"], site); const line = out.split("\n").map((one) => one.trim()).find((one) => one.startsWith("push")) ?? "push: no count line"; log(line); await writeHead(s3, head); log(`head ${short(head.sha)}`); return line; } /* HANDLER */ async function once(event: Event): Promise { const began = Date.now(); mark = began; const s3 = DRY ? null : client(need("CARLOMITCHENER_BUCKET")); const stored = await readHead(s3); const head: Head = { sha: event.sha ?? "", etag: event.sha ? "" : stored.etag, shop: stored.shop }; if (!head.sha) { const fresh = await commit(stored.etag); head.sha = fresh ? fresh.sha : stored.sha; head.etag = fresh ? fresh.etag : stored.etag; } if (!head.sha) throw new Error("site: no sha in the event, in the head or from github"); const source: Source = event.source ?? "manual"; const same = head.sha === stored.sha; if (same && source !== "automator") { if (head.etag !== stored.etag) await writeHead(s3, head); log(`unchanged ${short(head.sha)}`); return `unchanged ${short(head.sha)}`; } log(`commit ${short(head.sha)} from ${source}`); const line = await make(s3, head, stored, same); console.log(`done ${short(head.sha)} ${Date.now() - began}ms`); return line; } async function handler(request?: Request): Promise { let event: Event = { source: "manual" }; if (request) { try { event = (await request.json()) as Event; } catch { event = { source: "manual" }; } } return new Response(`${await once(event)}\n`); } export default { fetch: handler }; /* MAIN */ if (import.meta.main) { const raw = process.argv[2] ?? process.env.EVENT ?? '{"source":"manual"}'; console.log(await once(JSON.parse(raw) as Event)); process.exit(0); }