// vite and its plugins live only as long as one frontend build subprocess. import { encodeBase64 } from "@std/encoding/base64"; import { fromFileUrl, isAbsolute } from "@std/path"; import aftercare from "@char/aftercare/vite"; import deno from "@deno/vite-plugin"; import { build, type Plugin, type Rolldown } from "vite"; import { HOST_GLOBAL, HOST_MODULES } from "@berry/api/host.ts"; export type FrontendBuild = { chunks: { fileName: string; code: string; isEntry: boolean }[]; assets: { fileName: string; source: string; binary: boolean }[]; dependencies: string[]; }; const isHost = (specifier: unknown): specifier is string => (HOST_MODULES as readonly unknown[]).includes(specifier); /** Imports of the host's modules become reads of the instances the app published, as `const { … } = host[…]`. */ function hostModules(): Plugin { return { name: "berry-host-modules", renderChunk(code) { let rewritten = ""; let at = 0; for (const node of this.parse(code).body) { if (node.type !== "ImportDeclaration" || !isHost(node.source.value)) continue; const host = `globalThis[${JSON.stringify(HOST_GLOBAL)}][${ JSON.stringify(node.source.value) }]`; const bindings: string[] = []; const statements: string[] = []; for (const specifier of node.specifiers) { if (specifier.type === "ImportNamespaceSpecifier") { statements.push(`const ${specifier.local.name} = ${host};`); continue; } const imported = specifier.type === "ImportDefaultSpecifier" ? "default" : specifier.imported.type === "Identifier" ? specifier.imported.name : JSON.stringify(specifier.imported.value); bindings.push( imported === specifier.local.name ? imported : `${imported}: ${specifier.local.name}`, ); } if (bindings.length > 0) statements.push(`const { ${bindings.join(", ")} } = ${host};`); // one line for one line, so the source map still lines up rewritten += code.slice(at, node.start) + statements.join(" "); at = node.end; } return at === 0 ? null : { code: rewritten + code.slice(at), map: null }; }, }; } async function buildFrontend(entry: string): Promise { const root = fromFileUrl(new URL("../", import.meta.url)); const dependencies = new Set([entry]); function track( hook: (this: C, ...args: A) => R, ): (this: C, ...args: A) => R { return function (this: C, ...args: A): R { const context = new Proxy(this, { get(target, key) { if (key === "addWatchFile") { return (path: string) => { if (isAbsolute(path)) dependencies.add(path); target.addWatchFile(path); }; } const value = Reflect.get(target, key, target); return typeof value === "function" ? value.bind(target) : value; }, }); return Reflect.apply(hook, context, args); }; } const result = await build({ root, configFile: false, mode: "production", // a failure rejects with its message; the caller reports it logLevel: "silent", publicDir: false, // assets resolve against the module that names them, beside it under /ext// base: "./", plugins: [ deno({ onLoad({ id }) { if (id.startsWith("file:")) dependencies.add(fromFileUrl(id)); }, }), aftercare(), hostModules(), { name: "berry-build-inputs", configResolved(config) { // vite hides rolldown's watched files, including css imports and worker dependencies for (const plugin of config.plugins) { const load = plugin.load; if (load) { plugin.load = typeof load === "function" ? track(load) : { ...load, handler: track(load.handler) }; } const transform = plugin.transform; if (transform) { plugin.transform = typeof transform === "function" ? track(transform) : { ...transform, handler: track(transform.handler) }; } } }, generateBundle() { for (const id of this.getModuleIds()) { const path = id.split("?")[0]; if (isAbsolute(path)) dependencies.add(path); } }, }, ], build: { write: false, minify: false, sourcemap: "inline", modulePreload: false, // not library mode, which inlines every asset however large, such as a wasm module rolldownOptions: { input: entry, external: [...HOST_MODULES], preserveEntrySignatures: "strict", output: { format: "es", // a dynamic import is a chunk of its own, so an extension can leave heavy code until it needs it entryFileNames: "[name]-[hash].js", chunkFileNames: "[name]-[hash].js", assetFileNames: "[name]-[hash][extname]", }, }, }, }); const outputs = (Array.isArray(result) ? result : [result as Rolldown.RolldownOutput]) .flatMap((r) => r.output); return { chunks: outputs.filter((o): o is Rolldown.OutputChunk => o.type === "chunk") .map(({ fileName, code, isEntry }) => ({ fileName, code, isEntry })), assets: outputs.filter((o): o is Rolldown.OutputAsset => o.type === "asset") .map(({ fileName, source }) => ({ fileName, source: typeof source === "string" ? source : encodeBase64(source), binary: typeof source !== "string", })), dependencies: [...dependencies], }; } if (import.meta.main) { try { console.log(JSON.stringify(await buildFrontend(Deno.args[0]))); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); Deno.exit(1); } }