// Each extension's `web.tsx`, built by vite on its own, apart from the app's bundle, and served for the app to import. import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { encodeHex } from "@std/encoding/hex"; import { fromFileUrl, join, resolve } from "@std/path"; import type { FrontendSource } from "@berry/api/extension-startup.ts"; import { denoExecutable } from "./deno.ts"; import type { FrontendBuild } from "./web-build.ts"; import { HOST_GLOBAL, HOST_MODULES } from "@berry/api/host.ts"; /** Where the app finds an extension's frontend. */ export type WebEntry = { module: string; styles: string[] }; const TYPES: Record = { wasm: "application/wasm", svg: "image/svg+xml", png: "image/png", woff2: "font/woff2", json: "application/json", }; const contentType = (file: string) => TYPES[file.split(".").at(-1) ?? ""] ?? "application/octet-stream"; async function hash(text: string) { return encodeHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))).slice( 0, 16, ); } type CachedBuild = { pipeline: string; inputs: [string, string][]; entry: WebEntry; files: { path: string; body: string; digest: string; binary: boolean; type: string }[]; }; const root = fromFileUrl(new URL("../", import.meta.url)); const builder = fromFileUrl(new URL("./web-build.ts", import.meta.url)); const pipelineFiles = [ fromFileUrl(import.meta.url), builder, join(root, "deno.json"), join(root, "deno.lock"), ]; // the running build pipeline may predate edits that are waiting for a restart const pipeline = await hash(JSON.stringify([ Deno.version.deno, HOST_GLOBAL, HOST_MODULES, ...await Promise.all(pipelineFiles.map((path) => Deno.readTextFile(path))), ])); async function inputs(paths: readonly string[]) { return await Promise.all( [...new Set(paths)].sort().map(async (path): Promise<[string, string]> => { if ((await Deno.stat(path)).isDirectory) { const entries = []; for await (const entry of Deno.readDir(path)) { entries.push([entry.name, entry.isDirectory, entry.isSymlink]); } return [path, await hash(JSON.stringify(entries.sort()))]; } const bytes = await Deno.readFile(path); return [path, encodeHex(await crypto.subtle.digest("SHA-256", bytes))]; }), ); } export function extensionWeb({ cacheDirectory }: { cacheDirectory?: string; } = {}) { /** Built files by url path; a build replaces its extension's files, and content-hashed names never change meaning. */ const files = new Map; type: string }>(); const paths = new Map(); return { /** Build an extension's frontend; a failed build leaves the previous one in place and rejects. */ async build( name: string, entry: string, onSource?: (source: FrontendSource) => void, ): Promise { entry = resolve(entry); const builtAfter = Date.now(); const cache = cacheDirectory === undefined ? undefined : join( cacheDirectory, `${await hash(JSON.stringify([name, entry]))}.json`, ); if (cache !== undefined) { try { const cached: CachedBuild = JSON.parse(await Deno.readTextFile(cache)); if ( cached.pipeline === pipeline && JSON.stringify(cached.inputs) === JSON.stringify(await inputs(cached.inputs.map(([path]) => path))) ) { const restored = await Promise.all(cached.files.map(async (file) => { if (await hash(file.body) !== file.digest) throw new Error("damaged artifact"); return [file.path, { body: file.binary ? decodeBase64(file.body) : file.body, type: file.type, }] as const; })); if ( [cached.entry.module, ...cached.entry.styles].some((path) => !restored.some(([served]) => served === path) ) ) throw new Error("missing artifact"); for (const path of paths.get(name) ?? []) files.delete(path); for (const [path, file] of restored) files.set(path, file); paths.set(name, restored.map(([path]) => path)); onSource?.("cache"); return cached.entry; } } catch { // a missing, stale, or damaged cache is just a rebuild } } onSource?.("build"); const { success, code, stdout, stderr } = await new Deno.Command(denoExecutable(), { args: ["run", "-A", "--quiet", "--config", join(root, "deno.json"), builder, entry], cwd: root, stdout: "piped", stderr: "piped", }).output(); if (!success) { throw new Error( new TextDecoder().decode(stderr).trim() || `frontend build exited with code ${code}`, ); } const { chunks, assets, dependencies }: FrontendBuild = JSON.parse( new TextDecoder().decode(stdout), ); const css = assets.filter((o) => o.fileName.endsWith(".css")) .map((o) => String(o.source)) .join("\n"); // chunks import each other by name, so the module keeps its content-hashed one const module = `/ext/${name}/${chunks.find((chunk) => chunk.isEntry)!.fileName}`; const styles = css.trim() === "" ? [] : [`/ext/${name}/${await hash(css)}.css`]; for (const path of paths.get(name) ?? []) files.delete(path); const scripts = chunks.map((chunk) => { const path = `/ext/${name}/${chunk.fileName}`; files.set(path, { body: chunk.code, type: "text/javascript; charset=utf-8" }); return path; }); if (styles.length > 0) files.set(styles[0], { body: css, type: "text/css; charset=utf-8" }); const others = assets.filter((o) => !o.fileName.endsWith(".css")).map((asset) => { const path = `/ext/${name}/${asset.fileName}`; const body = asset.binary ? decodeBase64(asset.source) : asset.source; files.set(path, { body, type: contentType(asset.fileName) }); return path; }); paths.set(name, [...scripts, ...styles, ...others]); const built = { module, styles }; if (cache !== undefined) { let temporary: string | undefined; try { const fingerprints = await inputs([...pipelineFiles, ...dependencies]); // do not cache a build whose inputs changed while vite was reading them const changed = await Promise.all( fingerprints.map(async ([path]) => (await Deno.stat(path)).mtime!.getTime() > builtAfter ), ); if (!changed.some(Boolean)) { const cached: CachedBuild = { pipeline, inputs: fingerprints, entry: built, files: await Promise.all(paths.get(name)!.map(async (path) => { const { body, type } = files.get(path)!; const encoded = typeof body === "string" ? body : encodeBase64(body); return { path, body: encoded, digest: await hash(encoded), binary: typeof body !== "string", type, }; })), }; await Deno.mkdir(cacheDirectory!, { recursive: true, mode: 0o700 }); temporary = await Deno.makeTempFile({ dir: cacheDirectory, prefix: ".build-" }); await Deno.writeTextFile(temporary, JSON.stringify(cached)); await Deno.rename(temporary, cache); } } catch (error) { console.error(`[berry] ${name} frontend cache:`, error); } finally { if (temporary !== undefined) await Deno.remove(temporary).catch(() => {}); } } return built; }, /** Answers a request for a built file; `undefined` for anything else. */ serve(path: string): Response | undefined { const file = files.get(path); if (file === undefined) return undefined; return new Response(file.body, { headers: { "content-type": file.type, "cache-control": "public, max-age=31536000, immutable", }, }); }, }; } export type ExtensionWeb = ReturnType;