push.ts

7.2 kB · typescript · 183 lines

1import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";2import { tmpdir } from "node:os";3import { dirname, join } from "node:path";4import { build, digest, globals, today, type Manifest, type Output, type Spec } from "./ssg/build.ts";5import { client, del, getText, need, putBytes } from "./s3.ts";67/* WHERE */89export type Store = { bucket: string; prefix: string };1011export type Block = { prefix: string; guard: string[]; bucket: string; store: Store; hashed: string[] };1213const ASSET = "@";14const BATCH = 32;1516export function block(spec: Spec): Block {17  const config = spec.config ?? (JSON.parse(readFileSync(join(spec.root, "site.json"), "utf8")) as Record<string, unknown>);18  const found = config.push as Block | undefined;19  if (!found) throw new Error("push: site.json has no push block");20  return found;21}2223/* HEADERS */2425export const IMMUTABLE = "public, max-age=31536000, immutable";26export const REVALIDATE = "public, max-age=0, must-revalidate";2728const TYPES: Record<string, string> = {29  html: "text/html; charset=utf-8",30  js: "text/javascript; charset=utf-8",31  mjs: "text/javascript; charset=utf-8",32  css: "text/css; charset=utf-8",33  json: "application/json",34  map: "application/json",35  webmanifest: "application/manifest+json",36  xml: "application/xml",37  txt: "text/plain; charset=utf-8",38  md: "text/markdown; charset=utf-8",39  tex: "text/plain; charset=utf-8",40  wasm: "application/wasm",41  svg: "image/svg+xml",42  png: "image/png",43  jpg: "image/jpeg",44  jpeg: "image/jpeg",45  gif: "image/gif",46  webp: "image/webp",47  ico: "image/x-icon",48  pdf: "application/pdf",49  woff2: "font/woff2",50};5152export const kind = (path: string) => TYPES[(path.match(/\.([^./]+)$/)?.[1] ?? "").toLowerCase()] ?? "application/octet-stream";5354export const cache = (path: string, hashed: RegExp[]) => (hashed.some((re) => re.test(path)) ? IMMUTABLE : REVALIDATE);5556export const rules = (hashed: string[]) => hashed.map((one) => new RegExp(one));5758/* GUARD */5960export const mine = (path: string, guard: string[]) => !guard.some((one) => path.startsWith(one));6162type Page = {63  contents?: { key: string }[];64  commonPrefixes?: ({ prefix: string } | string)[];65  isTruncated?: boolean;66  nextContinuationToken?: string | null;67};6869export type Lister = { list: (options: { prefix: string; delimiter: string; maxKeys: number; continuationToken?: string }) => Promise<Page | null> };7071export async function sweep(s3: Lister, root: string, guard: string[], at = root, out: string[] = []): Promise<string[]> {72  let token: string | undefined;73  do {74    const page = await s3.list({ prefix: at, delimiter: "/", maxKeys: 1000, continuationToken: token });75    for (const item of page?.contents ?? []) out.push(item.key.slice(root.length));76    for (const one of page?.commonPrefixes ?? []) {77      const next = typeof one === "string" ? one : one.prefix;78      if (!guard.some((each) => next === root + each)) await sweep(s3, root, guard, next, out);79    }80    token = page?.isTruncated ? (page.nextContinuationToken ?? undefined) : undefined;81  } while (token);82  return out;83}8485/* MANIFEST */8687export function assets(items: Output[], old: Manifest): Manifest {88  const out: Manifest = {};89  for (const item of items) {90    const body = typeof item.bytes === "string" ? new TextEncoder().encode(item.bytes) : item.bytes;91    const hash = digest([body]).slice(0, 16);92    const was = old[ASSET + item.path];93    out[ASSET + item.path] = was && was.hash === hash ? was : { hash, at: today(), outputs: [item.path] };94  }95  return out;96}9798export function typing(manifest: Manifest): Map<string, string> {99  const out = new Map<string, string>();100  for (const record of Object.values(manifest)) for (const [path, type] of Object.entries(record.types ?? {})) out.set(path, type);101  return out;102}103104export function spread(manifest: Manifest): Map<string, string> {105  const out = new Map<string, string>();106  for (const [key, record] of Object.entries(manifest)) for (const path of record.outputs) out.set(path, key);107  return out;108}109110/* LOCAL */111112export const holding = () => process.env.DRY === "1";113114const where = (store: Store) => join(process.env.DRY_DIR ?? join(tmpdir(), "push", store.prefix), "manifest.json");115116const held = (path: string) => (existsSync(path) ? readFileSync(path, "utf8") : null);117118function hold(path: string, text: string) {119  mkdirSync(dirname(path), { recursive: true });120  writeFileSync(path, text);121}122123/* PUSH */124125export async function push(spec: Spec, options: { dry?: boolean } = {}): Promise<{ rendered: number; uploaded: number; deleted: number }> {126  const conf = block(spec);127  const hashed = rules(conf.hashed);128  const local = holding();129  const site = local ? null : client(need(conf.bucket));130  const store = local ? null : conf.store.bucket === conf.bucket ? site : client(need(conf.store.bucket));131  const key = `${conf.store.prefix}/manifest.json`;132  const found = store ? await getText(store, key) : held(where(conf.store));133  const old: Manifest = found ? JSON.parse(found) : {};134  const carry = join(tmpdir(), `push-remote-${process.pid}.json`);135  writeFileSync(carry, JSON.stringify(old, null, 2) + "\n");136  const done = await build(spec, { manifest: carry, verify: false });137  rmSync(carry, { force: true });138  const next: Manifest = { ...done.manifest, ...assets(await globals(done.site, spec), old) };139  const want = spread(next);140  const had = spread(old);141  const types = typing(next);142  const upload: string[] = [];143  for (const [path, name] of want) {144    if (!mine(path, conf.guard)) continue;145    const was = old[name];146    if (was && was.hash === next[name]!.hash && had.has(path)) continue;147    upload.push(path);148  }149  const seen = found || !site ? [...had.keys()] : await sweep(site, conf.prefix, conf.guard);150  const remove = seen.filter((path) => mine(path, conf.guard) && !want.has(path));151  const header = (path: string) => (types.has(path) ? REVALIDATE : cache(path, hashed));152  const text = JSON.stringify(next, null, 2) + "\n";153  if (options.dry) {154    const fixed = upload.filter((path) => header(path) === IMMUTABLE);155    for (const path of fixed) console.log(`immutable ${conf.prefix + path}`);156    console.log(`${upload.length - fixed.length} more at max-age 0, ${remove.length} to delete`);157  } else if (site && store) {158    for (let i = 0; i < upload.length; i += BATCH) {159      await Promise.all(160        upload.slice(i, i + BATCH).map((path) =>161          putBytes(site, conf.prefix + path, new Uint8Array(readFileSync(join(done.site.out, path))), {162            type: types.get(path) ?? kind(path),163            cacheControl: header(path),164          }),165        ),166      );167    }168    if (remove.length) await del(site, remove.map((path) => conf.prefix + path), BATCH);169    await putBytes(store, key, text, { type: "application/json", cacheControl: REVALIDATE });170  } else hold(where(conf.store), text);171  return { rendered: done.rendered, uploaded: upload.length, deleted: remove.length };172}173174/* MAIN */175176export async function main(spec: Spec): Promise<void> {177  const dry = process.argv.includes("--dry");178  const done = await push(spec, { dry });179  const guard = block(spec).guard.join(", ");180  console.log(181    `push${dry ? " --dry" : ""}${holding() ? " --local" : ""}: ${done.rendered} rendered, ${done.uploaded} uploaded, ${done.deleted} deleted, ${guard} guarded`,182  );183}