push.ts

4.7 kB · typescript · 130 lines

1import { readFileSync, rmSync, writeFileSync } from "node:fs";2import { tmpdir } from "node:os";3import { join } from "node:path";4import { build, digest, globals, today, type Manifest, type Output } from "../kit/ssg/build.ts";5import { client, del, getText, list, putBytes, DEV_BUCKET, NET_BUCKET } from "../../aws/s3.ts";6import { spec } from "./site.ts";78/* WHERE */910const REMOTE = "build/net/manifest.json";11const CDN = "cdn/";12const ASSET = "@";13const BATCH = 32;1415/* HEADERS */1617const IMMUTABLE = "public, max-age=31536000, immutable";18const REVALIDATE = "public, max-age=0, must-revalidate";1920const HASHED = [/(^|\/)lib-[^/]+\.js$/, /(^|\/)lib-[^/]+\.css$/, /\.wasm$/, /-[0-9a-f]{8}\.[^./]+$/];2122const TYPES: Record<string, string> = {23  html: "text/html; charset=utf-8",24  js: "text/javascript; charset=utf-8",25  mjs: "text/javascript; charset=utf-8",26  css: "text/css; charset=utf-8",27  json: "application/json",28  map: "application/json",29  webmanifest: "application/manifest+json",30  xml: "application/xml",31  txt: "text/plain; charset=utf-8",32  md: "text/markdown; charset=utf-8",33  tex: "text/plain; charset=utf-8",34  wasm: "application/wasm",35  svg: "image/svg+xml",36  png: "image/png",37  jpg: "image/jpeg",38  jpeg: "image/jpeg",39  gif: "image/gif",40  webp: "image/webp",41  ico: "image/x-icon",42  pdf: "application/pdf",43  woff2: "font/woff2",44};4546const kind = (path: string) => TYPES[(path.match(/\.([^./]+)$/)?.[1] ?? "").toLowerCase()] ?? "application/octet-stream";4748const cache = (path: string) => (HASHED.some((re) => re.test(path)) ? IMMUTABLE : REVALIDATE);4950/* CDN */5152const mine = (path: string) => !path.startsWith(CDN);5354/* MANIFEST */5556function assets(items: Output[], old: Manifest): Manifest {57  const out: Manifest = {};58  for (const item of items) {59    const body = typeof item.bytes === "string" ? new TextEncoder().encode(item.bytes) : item.bytes;60    const hash = digest([body]).slice(0, 16);61    const was = old[ASSET + item.path];62    out[ASSET + item.path] = was && was.hash === hash ? was : { hash, at: today(), outputs: [item.path] };63  }64  return out;65}6667function typing(manifest: Manifest): Map<string, string> {68  const out = new Map<string, string>();69  for (const record of Object.values(manifest)) for (const [path, type] of Object.entries(record.types ?? {})) out.set(path, type);70  return out;71}7273function spread(manifest: Manifest): Map<string, string> {74  const out = new Map<string, string>();75  for (const [key, record] of Object.entries(manifest)) for (const path of record.outputs) out.set(path, key);76  return out;77}7879/* PUSH */8081export async function push(options: { dry?: boolean } = {}): Promise<{ rendered: number; uploaded: number; deleted: number }> {82  const dev = client(DEV_BUCKET);83  const net = client(NET_BUCKET);84  const found = await getText(dev, REMOTE);85  const old: Manifest = found ? JSON.parse(found) : {};86  const carry = join(tmpdir(), `mrlynet-remote-${process.pid}.json`);87  writeFileSync(carry, JSON.stringify(old, null, 2) + "\n");88  const done = await build(spec, { manifest: carry, verify: false });89  rmSync(carry, { force: true });90  const next: Manifest = { ...done.manifest, ...assets(await globals(done.site, spec), old) };91  const want = spread(next);92  const had = spread(old);93  const types = typing(next);94  const upload: string[] = [];95  for (const [path, key] of want) {96    const was = old[key];97    if (was && was.hash === next[key]!.hash && had.has(path)) continue;98    if (mine(path)) upload.push(path);99  }100  const seen = found ? [...had.keys()] : await list(net);101  const remove = seen.filter((path) => mine(path) && !want.has(path));102  const header = (path: string) => (types.has(path) ? REVALIDATE : cache(path));103  if (options.dry) {104    const fixed = upload.filter((path) => header(path) === IMMUTABLE);105    for (const path of fixed) console.log(`immutable ${path}`);106    console.log(`${upload.length - fixed.length} more at max-age 0, ${remove.length} to delete`);107  } else {108    for (let i = 0; i < upload.length; i += BATCH) {109      await Promise.all(110        upload.slice(i, i + BATCH).map((path) =>111          putBytes(net, path, new Uint8Array(readFileSync(join(done.site.out, path))), {112            type: types.get(path) ?? kind(path),113            cacheControl: header(path),114          }),115        ),116      );117    }118    if (remove.length) await del(net, remove, BATCH);119    await putBytes(dev, REMOTE, JSON.stringify(next, null, 2) + "\n", { type: "application/json", cacheControl: REVALIDATE });120  }121  return { rendered: done.rendered, uploaded: upload.length, deleted: remove.length };122}123124/* MAIN */125126if (import.meta.main) {127  const dry = process.argv.includes("--dry");128  const done = await push({ dry });129  console.log(`push${dry ? " --dry" : ""}: ${done.rendered} rendered, ${done.uploaded} uploaded, ${done.deleted} deleted, cdn/ guarded`);130}