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}