push.ts
5.7 kB · typescript · 158 lines
1import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";2import { tmpdir } from "node:os";3import { dirname, join } from "node:path";4import { spec } from "./build.ts";5import { need } from "../lib/env.ts";6import { client, del, getText, putBytes } from "../../aws/s3.ts";7import type { S3Client } from "bun";8import type { Manifest, Output } from "kit/ssg/build.ts";910const { build, digest, globals, today } = await import("kit/ssg/build.ts");1112/* WHERE */1314const PREFIX = "site/";15const GUARD = ["cdn/", "art/"];16const REMOTE = "data/build/manifest.json";17const ASSET = "@";18const BATCH = 32;19const DRY = process.env.DRY === "1";20const HOLD = join(process.env.DRY_DIR ?? "/tmp/carlomitchener", "manifest.json");2122/* HEADERS */2324const IMMUTABLE = "public, max-age=31536000, immutable";25const REVALIDATE = "public, max-age=0, must-revalidate";2627const HASHED = [/(^|\/)lib-[^/]+\.js$/, /(^|\/)lib-[^/]+\.css$/, /\.wasm$/, /-[0-9a-f]{8}\.[^./]+$/];2829const TYPES: Record<string, string> = {30 html: "text/html; charset=utf-8",31 js: "text/javascript; charset=utf-8",32 mjs: "text/javascript; charset=utf-8",33 css: "text/css; charset=utf-8",34 json: "application/json",35 map: "application/json",36 webmanifest: "application/manifest+json",37 xml: "application/xml",38 txt: "text/plain; charset=utf-8",39 md: "text/markdown; charset=utf-8",40 tex: "text/plain; charset=utf-8",41 wasm: "application/wasm",42 svg: "image/svg+xml",43 png: "image/png",44 jpg: "image/jpeg",45 jpeg: "image/jpeg",46 gif: "image/gif",47 webp: "image/webp",48 ico: "image/x-icon",49 pdf: "application/pdf",50 woff2: "font/woff2",51};5253const kind = (path: string) => TYPES[(path.match(/\.([^./]+)$/)?.[1] ?? "").toLowerCase()] ?? "application/octet-stream";5455const cache = (path: string) => (HASHED.some((re) => re.test(path)) ? IMMUTABLE : REVALIDATE);5657/* GUARD */5859const mine = (path: string) => !GUARD.some((one) => path.startsWith(one));6061async function sweep(s3: S3Client, prefix: string, out: string[]): Promise<string[]> {62 let token: string | undefined;63 do {64 const page = await s3.list({ prefix, delimiter: "/", maxKeys: 1000, continuationToken: token });65 for (const item of page?.contents ?? []) out.push(item.key.slice(PREFIX.length));66 for (const one of page?.commonPrefixes ?? []) {67 const next = typeof one === "string" ? one : one.prefix;68 if (!GUARD.some((one) => next === PREFIX + one)) await sweep(s3, next, out);69 }70 token = page?.isTruncated ? (page.nextContinuationToken ?? undefined) : undefined;71 } while (token);72 return out;73}7475/* MANIFEST */7677function assets(items: Output[], old: Manifest): Manifest {78 const out: Manifest = {};79 for (const item of items) {80 const body = typeof item.bytes === "string" ? new TextEncoder().encode(item.bytes) : item.bytes;81 const hash = digest([body]).slice(0, 16);82 const was = old[ASSET + item.path];83 out[ASSET + item.path] = was && was.hash === hash ? was : { hash, at: today(), outputs: [item.path] };84 }85 return out;86}8788function typing(manifest: Manifest): Map<string, string> {89 const out = new Map<string, string>();90 for (const record of Object.values(manifest)) for (const [path, type] of Object.entries(record.types ?? {})) out.set(path, type);91 return out;92}9394function spread(manifest: Manifest): Map<string, string> {95 const out = new Map<string, string>();96 for (const [key, record] of Object.entries(manifest)) for (const path of record.outputs) out.set(path, key);97 return out;98}99100/* LOCAL */101102const held = () => (existsSync(HOLD) ? readFileSync(HOLD, "utf8") : null);103104function hold(text: string) {105 mkdirSync(dirname(HOLD), { recursive: true });106 writeFileSync(HOLD, text);107}108109/* PUSH */110111export async function push(options: { dry?: boolean } = {}): Promise<{ rendered: number; uploaded: number; deleted: number }> {112 const s3 = DRY ? null : client(need("CARLOMITCHENER_BUCKET"));113 const found = s3 ? await getText(s3, REMOTE) : held();114 const old: Manifest = found ? JSON.parse(found) : {};115 const carry = join(tmpdir(), `carlomitchener-remote-${process.pid}.json`);116 writeFileSync(carry, JSON.stringify(old, null, 2) + "\n");117 const done = await build(spec, { manifest: carry, verify: false });118 rmSync(carry, { force: true });119 const next: Manifest = { ...done.manifest, ...assets(await globals(done.site, spec), old) };120 const want = spread(next);121 const had = spread(old);122 const types = typing(next);123 const upload: string[] = [];124 for (const [path, key] of want) {125 if (!mine(path)) continue;126 const was = old[key];127 if (was && was.hash === next[key]!.hash && had.has(path)) continue;128 upload.push(path);129 }130 const seen = found || !s3 ? [...had.keys()] : await sweep(s3, PREFIX, []);131 const remove = seen.filter((path) => mine(path) && !want.has(path));132 const text = JSON.stringify(next, null, 2) + "\n";133 if (!options.dry) {134 if (s3) {135 for (let i = 0; i < upload.length; i += BATCH) {136 await Promise.all(137 upload.slice(i, i + BATCH).map((path) =>138 putBytes(s3, PREFIX + path, new Uint8Array(readFileSync(join(done.site.out, path))), {139 type: types.get(path) ?? kind(path),140 cacheControl: types.has(path) ? REVALIDATE : cache(path),141 }),142 ),143 );144 }145 if (remove.length) await del(s3, remove.map((path) => PREFIX + path), BATCH);146 await putBytes(s3, REMOTE, text, { type: "application/json", cacheControl: REVALIDATE });147 } else hold(text);148 }149 return { rendered: done.rendered, uploaded: upload.length, deleted: remove.length };150}151152/* MAIN */153154if (import.meta.main) {155 const dry = process.argv.includes("--dry");156 const done = await push({ dry });157 console.log(`push${dry ? " --dry" : ""}${DRY ? " --local" : ""}: ${done.rendered} rendered, ${done.uploaded} uploaded, ${done.deleted} deleted`);158}