char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somdo web builds in a subprocessd1201e2

main
8.2 KiB213 linesraw
1// Each extension's `web.tsx`, built by vite on its own, apart from the app's bundle, and served for the app to import.
2
3import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
4import { encodeHex } from "@std/encoding/hex";
5import { fromFileUrl, join, resolve } from "@std/path";
6import type { FrontendSource } from "@berry/api/extension-startup.ts";
7import { denoExecutable } from "./deno.ts";
8import type { FrontendBuild } from "./web-build.ts";
9import { HOST_GLOBAL, HOST_MODULES } from "@berry/api/host.ts";
10
11/** Where the app finds an extension's frontend. */
12export type WebEntry = { module: string; styles: string[] };
13
14const TYPES: Record<string, string> = {
15  wasm: "application/wasm",
16  svg: "image/svg+xml",
17  png: "image/png",
18  woff2: "font/woff2",
19  json: "application/json",
20};
21
22const contentType = (file: string) =>
23  TYPES[file.split(".").at(-1) ?? ""] ?? "application/octet-stream";
24
25async function hash(text: string) {
26  return encodeHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))).slice(
27    0,
28    16,
29  );
30}
31
32type CachedBuild = {
33  pipeline: string;
34  inputs: [string, string][];
35  entry: WebEntry;
36  files: { path: string; body: string; digest: string; binary: boolean; type: string }[];
37};
38
39const root = fromFileUrl(new URL("../", import.meta.url));
40const builder = fromFileUrl(new URL("./web-build.ts", import.meta.url));
41const pipelineFiles = [
42  fromFileUrl(import.meta.url),
43  builder,
44  join(root, "deno.json"),
45  join(root, "deno.lock"),
46];
47// the running build pipeline may predate edits that are waiting for a restart
48const pipeline = await hash(JSON.stringify([
49  Deno.version.deno,
50  HOST_GLOBAL,
51  HOST_MODULES,
52  ...await Promise.all(pipelineFiles.map((path) => Deno.readTextFile(path))),
53]));
54
55async function inputs(paths: readonly string[]) {
56  return await Promise.all(
57    [...new Set(paths)].sort().map(async (path): Promise<[string, string]> => {
58      if ((await Deno.stat(path)).isDirectory) {
59        const entries = [];
60        for await (const entry of Deno.readDir(path)) {
61          entries.push([entry.name, entry.isDirectory, entry.isSymlink]);
62        }
63        return [path, await hash(JSON.stringify(entries.sort()))];
64      }
65      const bytes = await Deno.readFile(path);
66      return [path, encodeHex(await crypto.subtle.digest("SHA-256", bytes))];
67    }),
68  );
69}
70
71export function extensionWeb({ cacheDirectory }: {
72  cacheDirectory?: string;
73} = {}) {
74  /** Built files by url path; a build replaces its extension's files, and content-hashed names never change meaning. */
75  const files = new Map<string, { body: string | Uint8Array<ArrayBuffer>; type: string }>();
76  const paths = new Map<string, string[]>();
77
78  return {
79    /** Build an extension's frontend; a failed build leaves the previous one in place and rejects. */
80    async build(
81      name: string,
82      entry: string,
83      onSource?: (source: FrontendSource) => void,
84    ): Promise<WebEntry> {
85      entry = resolve(entry);
86      const builtAfter = Date.now();
87      const cache = cacheDirectory === undefined ? undefined : join(
88        cacheDirectory,
89        `${await hash(JSON.stringify([name, entry]))}.json`,
90      );
91      if (cache !== undefined) {
92        try {
93          const cached: CachedBuild = JSON.parse(await Deno.readTextFile(cache));
94          if (
95            cached.pipeline === pipeline &&
96            JSON.stringify(cached.inputs) ===
97              JSON.stringify(await inputs(cached.inputs.map(([path]) => path)))
98          ) {
99            const restored = await Promise.all(cached.files.map(async (file) => {
100              if (await hash(file.body) !== file.digest) throw new Error("damaged artifact");
101              return [file.path, {
102                body: file.binary ? decodeBase64(file.body) : file.body,
103                type: file.type,
104              }] as const;
105            }));
106            if (
107              [cached.entry.module, ...cached.entry.styles].some((path) =>
108                !restored.some(([served]) => served === path)
109              )
110            ) throw new Error("missing artifact");
111            for (const path of paths.get(name) ?? []) files.delete(path);
112            for (const [path, file] of restored) files.set(path, file);
113            paths.set(name, restored.map(([path]) => path));
114            onSource?.("cache");
115            return cached.entry;
116          }
117        } catch {
118          // a missing, stale, or damaged cache is just a rebuild
119        }
120      }
121      onSource?.("build");
122      const { success, code, stdout, stderr } = await new Deno.Command(denoExecutable(), {
123        args: ["run", "-A", "--quiet", "--config", join(root, "deno.json"), builder, entry],
124        cwd: root,
125        stdout: "piped",
126        stderr: "piped",
127      }).output();
128      if (!success) {
129        throw new Error(
130          new TextDecoder().decode(stderr).trim() || `frontend build exited with code ${code}`,
131        );
132      }
133      const { chunks, assets, dependencies }: FrontendBuild = JSON.parse(
134        new TextDecoder().decode(stdout),
135      );
136      const css = assets.filter((o) => o.fileName.endsWith(".css"))
137        .map((o) => String(o.source))
138        .join("\n");
139
140      // chunks import each other by name, so the module keeps its content-hashed one
141      const module = `/ext/${name}/${chunks.find((chunk) => chunk.isEntry)!.fileName}`;
142      const styles = css.trim() === "" ? [] : [`/ext/${name}/${await hash(css)}.css`];
143      for (const path of paths.get(name) ?? []) files.delete(path);
144      const scripts = chunks.map((chunk) => {
145        const path = `/ext/${name}/${chunk.fileName}`;
146        files.set(path, { body: chunk.code, type: "text/javascript; charset=utf-8" });
147        return path;
148      });
149      if (styles.length > 0) files.set(styles[0], { body: css, type: "text/css; charset=utf-8" });
150      const others = assets.filter((o) => !o.fileName.endsWith(".css")).map((asset) => {
151        const path = `/ext/${name}/${asset.fileName}`;
152        const body = asset.binary ? decodeBase64(asset.source) : asset.source;
153        files.set(path, { body, type: contentType(asset.fileName) });
154        return path;
155      });
156      paths.set(name, [...scripts, ...styles, ...others]);
157      const built = { module, styles };
158      if (cache !== undefined) {
159        let temporary: string | undefined;
160        try {
161          const fingerprints = await inputs([...pipelineFiles, ...dependencies]);
162          // do not cache a build whose inputs changed while vite was reading them
163          const changed = await Promise.all(
164            fingerprints.map(async ([path]) =>
165              (await Deno.stat(path)).mtime!.getTime() > builtAfter
166            ),
167          );
168          if (!changed.some(Boolean)) {
169            const cached: CachedBuild = {
170              pipeline,
171              inputs: fingerprints,
172              entry: built,
173              files: await Promise.all(paths.get(name)!.map(async (path) => {
174                const { body, type } = files.get(path)!;
175                const encoded = typeof body === "string" ? body : encodeBase64(body);
176                return {
177                  path,
178                  body: encoded,
179                  digest: await hash(encoded),
180                  binary: typeof body !== "string",
181                  type,
182                };
183              })),
184            };
185            await Deno.mkdir(cacheDirectory!, { recursive: true, mode: 0o700 });
186            temporary = await Deno.makeTempFile({ dir: cacheDirectory, prefix: ".build-" });
187            await Deno.writeTextFile(temporary, JSON.stringify(cached));
188            await Deno.rename(temporary, cache);
189          }
190        } catch (error) {
191          console.error(`[berry] ${name} frontend cache:`, error);
192        } finally {
193          if (temporary !== undefined) await Deno.remove(temporary).catch(() => {});
194        }
195      }
196      return built;
197    },
198
199    /** Answers a request for a built file; `undefined` for anything else. */
200    serve(path: string): Response | undefined {
201      const file = files.get(path);
202      if (file === undefined) return undefined;
203      return new Response(file.body, {
204        headers: {
205          "content-type": file.type,
206          "cache-control": "public, max-age=31536000, immutable",
207        },
208      });
209    },
210  };
211}
212
213export type ExtensionWeb = ReturnType<typeof extensionWeb>;