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}