site.ts
6.6 kB · typescript · 184 lines
1import type { S3Client } from "bun";2import { createHash } from "node:crypto";3import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";4import { join, resolve } from "node:path";5import { client, getText, putBytes } from "./s3.ts";6import { need } from "../site/lib/env.ts";78/* WHERE */910const SOURCE = "carlomitchener/carlomitchener";11const HEAD_KEY = "data/build/head";12const AGENT = "carlomitchener-site";13const SRC_DIR = "/tmp/src";14const CACHE_DIR = process.env.BUN_INSTALL_CACHE_DIR || "/tmp/bun/cache";15const DRY = process.env.DRY === "1";16const HOLD = process.env.DRY_DIR ?? "/tmp/carlomitchener";17const SRC = process.env.SRC ?? "";1819/* CLOCK */2021let mark = Date.now();2223function log(line: string) {24 const now = Date.now();25 console.log(`${line} ${now - mark}ms`);26 mark = now;27}2829/* HEAD */3031type Head = { sha: string; etag: string; shop: string };3233type Source = "push" | "schedule" | "automator" | "manual";3435type Event = { source?: Source; sha?: string };3637const short = (sha: string) => sha.slice(0, 7) || "none";3839const local = () => join(HOLD, "head");4041async function readHead(s3: S3Client | null): Promise<Head> {42 const text = s3 ? await getText(s3, HEAD_KEY) : existsSync(local()) ? readFileSync(local(), "utf8") : null;43 const [sha = "", etag = "", shop = ""] = (text ?? "").trim().split("\n");44 return { sha: sha.trim(), etag: etag.trim(), shop: shop.trim() };45}4647async function writeHead(s3: S3Client | null, head: Head): Promise<void> {48 const body = `${head.sha}\n${head.etag}\n${head.shop}\n`;49 if (!s3) {50 mkdirSync(HOLD, { recursive: true });51 writeFileSync(local(), body);52 return;53 }54 await putBytes(s3, HEAD_KEY, body, { type: "text/plain", cacheControl: "no-store" });55}5657/* GITHUB */5859async function commit(etag: string): Promise<{ sha: string; etag: string } | null> {60 const headers: Record<string, string> = { "user-agent": AGENT, accept: "application/vnd.github+json" };61 if (etag) headers["if-none-match"] = etag;62 const res = await fetch(`https://api.github.com/repos/${SOURCE}/commits/main`, { headers });63 if (res.status === 304) return null;64 if (!res.ok) throw new Error(`github ${res.status}: ${(await res.text()).slice(0, 200)}`);65 const body = (await res.json()) as { sha?: string };66 if (!body.sha) throw new Error("github: the commit carries no sha");67 return { sha: body.sha, etag: res.headers.get("etag") ?? "" };68}6970async function unpack(slug: string, ref: string, into: string): Promise<void> {71 const stage = `${into}.stage`;72 rmSync(stage, { recursive: true, force: true });73 rmSync(into, { recursive: true, force: true });74 const res = await fetch(`https://codeload.github.com/${slug}/tar.gz/${ref}`, { headers: { "user-agent": AGENT } });75 if (!res.ok) throw new Error(`codeload ${slug} ${ref}: ${res.status}`);76 const files = await new Bun.Archive(new Uint8Array(await res.arrayBuffer())).extract(stage);77 const [top] = readdirSync(stage);78 if (!files || !top) throw new Error(`codeload ${slug} ${ref}: the tarball is empty`);79 renameSync(join(stage, top), into);80 rmSync(stage, { recursive: true, force: true });81}8283/* CHILD */8485function childEnv(): Record<string, string> {86 const env = { ...(process.env as Record<string, string>), HOME: "/tmp", BUN_INSTALL_CACHE_DIR: CACHE_DIR };87 if (env.KIT) env.KIT = resolve(env.KIT);88 return env;89}9091async function run(cmd: string[], cwd: string): Promise<string> {92 const child = Bun.spawn(cmd, { cwd, env: childEnv(), stdout: "pipe", stderr: "pipe" });93 const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]);94 const code = await child.exited;95 if (code !== 0) throw new Error(`${cmd.join(" ")}: exit ${code}\n${(err || out).trim().slice(-1500)}`);96 return out;97}9899/* SNAPSHOT */100101function shopHash(site: string): string {102 const path = join(site, "data", "shop.json");103 if (!existsSync(path)) return "";104 const snapshot = JSON.parse(readFileSync(path, "utf8")) as { products?: unknown };105 return createHash("sha256").update(JSON.stringify(snapshot.products ?? [])).digest("hex").slice(0, 16);106}107108/* BUILD */109110async function make(s3: S3Client | null, head: Head, stored: Head, same: boolean): Promise<string> {111 const bun = process.execPath;112 if (SRC) log(`source ${SRC}`);113 else {114 await unpack(SOURCE, head.sha, SRC_DIR);115 log(`source ${short(head.sha)}`);116 }117 const site = join(SRC || SRC_DIR, "site");118 await run([bun, "scripts/kit.ts", "--hold"], site);119 log(`kit ${readFileSync(join(site, "kit.lock"), "utf8").trim().slice(0, 7)}`);120 await run([bun, "install", "--frozen-lockfile"], site);121 log("install");122 await run([bun, "run", DRY ? "fake" : "snapshot"], site);123 head.shop = shopHash(site);124 log(`snapshot ${head.shop || "none"}`);125 if (same && head.shop === stored.shop) {126 if (head.etag !== stored.etag) await writeHead(s3, head);127 return `unchanged ${short(head.sha)}, products same`;128 }129 const out = await run([bun, "run", "push"], site);130 const line = out.split("\n").map((one) => one.trim()).find((one) => one.startsWith("push")) ?? "push: no count line";131 log(line);132 await writeHead(s3, head);133 log(`head ${short(head.sha)}`);134 return line;135}136137/* HANDLER */138139async function once(event: Event): Promise<string> {140 const began = Date.now();141 mark = began;142 const s3 = DRY ? null : client(need("CARLOMITCHENER_BUCKET"));143 const stored = await readHead(s3);144 const head: Head = { sha: event.sha ?? "", etag: event.sha ? "" : stored.etag, shop: stored.shop };145 if (!head.sha) {146 const fresh = await commit(stored.etag);147 head.sha = fresh ? fresh.sha : stored.sha;148 head.etag = fresh ? fresh.etag : stored.etag;149 }150 if (!head.sha) throw new Error("site: no sha in the event, in the head or from github");151 const source: Source = event.source ?? "manual";152 const same = head.sha === stored.sha;153 if (same && source !== "automator") {154 if (head.etag !== stored.etag) await writeHead(s3, head);155 log(`unchanged ${short(head.sha)}`);156 return `unchanged ${short(head.sha)}`;157 }158 log(`commit ${short(head.sha)} from ${source}`);159 const line = await make(s3, head, stored, same);160 console.log(`done ${short(head.sha)} ${Date.now() - began}ms`);161 return line;162}163164async function handler(request?: Request): Promise<Response> {165 let event: Event = { source: "manual" };166 if (request) {167 try {168 event = (await request.json()) as Event;169 } catch {170 event = { source: "manual" };171 }172 }173 return new Response(`${await once(event)}\n`);174}175176export default { fetch: handler };177178/* MAIN */179180if (import.meta.main) {181 const raw = process.argv[2] ?? process.env.EVENT ?? '{"source":"manual"}';182 console.log(await once(JSON.parse(raw) as Event));183 process.exit(0);184}