async function trackEsbuild(io: Io, metafile: esbuild.Metafile) { await Promise.all(Object.keys(metafile.inputs) .filter(file => !isIgnoredSource(file)) .map(file => io.trackFile(file))); } // This file implements client-side bundling, mostly wrapping esbuild. export async function bundleClientJavaScript( io: Io, { clientRefs, extraPublicScripts, dev = false }: { clientRefs: string[]; extraPublicScripts: string[]; dev: boolean; }, ) { const entryPoints = [ ...new Set([ ...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts, ].map(toAbs)), ]; if (entryPoints.length === 0) return {}; const invalidFiles = entryPoints .filter((file) => !file.match(/\.client\.[tj]sx?/)); if (invalidFiles.length > 0) { const cwd = process.cwd(); throw new Error( "All client-side scripts should be named like '.client.ts'. Exceptions: \n" + invalidFiles.map((x) => path.join(cwd, x)).join("\n"), ); } const clientPlugins: esbuild.Plugin[] = [ projectRelativeResolution(), markoViaBuildCache(), ]; const bundle = await esbuild.build({ assetNames: "/asset/[hash]", bundle: true, chunkNames: "/js/c.[hash]", entryNames: "/js/[name]", entryPoints, format: "esm", jsx: "automatic", jsxDev: dev, jsxImportSource: "#ssr", logLevel: "silent", metafile: true, minify: !dev, outdir: "out!", plugins: clientPlugins, write: false, define: { "ASSERT": "console.assert", MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), }, }).catch((err: any) => { err.message = `Client ${err.message}`; throw err; }); if (bundle.errors.length || bundle.warnings.length) { throw new AggregateError( bundle.errors.concat(bundle.warnings), "JS bundle failed", ); } const publicScriptRoutes = extraPublicScripts.map((file) => "/js/" + path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace( /\.client\.[tj]sx?/, ".js", ) ); const { metafile, outputFiles } = bundle; const p = [] p.push(trackEsbuild(io, metafile)); const scripts: Record = {}; for (const file of outputFiles) { const { text } = file; let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/"); const { inputs } = UNWRAP(metafile.outputs["out!" + route]); const sources = Object.keys(inputs).filter((x) => !isIgnoredSource(x)); // Register non-chunks as script entries. const chunk = route.startsWith("/js/c."); if (!chunk) { const key = hot.getScriptId(path.resolve(sources[sources.length - 1])); route = "/js/" + key.replace(/\.client\.tsx?/, ".js"); scripts[key] = text; } // Register chunks and public scripts as assets. if (chunk || publicScriptRoutes.includes(route)) { p.push(io.writeAsset(route, text)); } } await Promise.all(p); return scripts; } export type ServerPlatform = "node" | "passthru"; export interface ServerSideOptions { entries: string[], viewItems: sg.FileItem[] viewRefs: incr.Ref[], styleMap: Map>; scriptMap: incr.Ref>; platform: ServerPlatform, } export async function bundleServerJavaScript( { viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform }: ServerSideOptions ) { const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => { const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_"); return { magicWord, file: [ ...viewItems.map((view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`), `const styles = ${magicWord}[-2]`, `export const scripts = ${magicWord}[-1]`, "export const views = {", ...viewItems.map((view, i) => [ ` ${JSON.stringify(view.id)}: {`, ` component: view${i}.default,`, ` meta: view${i}.meta,`, ` layout: view${i}.layout?.default ?? null,`, ` inlineCss: styles[${magicWord}[${i}]]`, ` },`, ].join("\n")), "}", ].join("\n") }; }, viewItems) const wBundles = entries.map(entry => [entry, incr.work(async (io, entry) => { const pkg = await io.readJson<{ dependencies: Record; }>("package.json"); let magicWord = null as string | null; // -- plugins -- const serverPlugins: esbuild.Plugin[] = [ virtualFiles({ // only add dependency when imported. "$views": async () => { const view = await io.readWork(wViewSource); ({ magicWord } = view); return view.file; }, }), projectRelativeResolution(), markoViaBuildCache(), { name: "replace client references", setup(b) { b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({ contents: hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code, loader: path.extname(file).slice(1) as esbuild.Loader, })); }, }, { name: "mark css external", setup(b) { b.onResolve( { filter: /\.css$/ }, () => ({ path: ".", namespace: "dropped" }), ); b.onLoad( { filter: /./, namespace: "dropped" }, () => ({ contents: "" }), ); }, }, ]; const { metafile, outputFiles, errors, warnings } = await esbuild.build({ bundle: true, chunkNames: "c.[hash]", entryNames: path.basename(entry, path.extname(entry)), entryPoints: [ path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"), ], platform: "node", format: "esm", minify: false, outdir: "out!", plugins: serverPlugins, splitting: true, logLevel: "silent", write: false, metafile: true, jsx: "automatic", jsxImportSource: "#ssr", jsxDev: false, define: { MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), 'globalThis.CLOVER_SERVER_ENTRY': JSON.stringify(entry), }, external: Object.keys(pkg.dependencies) .filter((x) => !x.startsWith("@paperclover")), }); await trackEsbuild(io, metafile) let fileWithMagicWord: { bytes: Buffer; basename: string; magicWord: string; } | null = null; for (const output of outputFiles) { const basename = output.path.replace(/^.*?!(?:\/|\\)/, ""); const key = "out!/" + basename.replaceAll("\\", "/"); // If this contains the generated "$views" file, then // mark this file as the one for replacement. Because // `splitting` is `true`, esbuild will not emit this // file in more than one chunk. if (magicWord && metafile.outputs[key].inputs["framework/lib/view.ts"]) { ASSERT(!fileWithMagicWord); fileWithMagicWord = { basename, bytes: Buffer.from(output.contents), magicWord, }; } else { io.writeFile(basename, Buffer.from(output.contents)) } } return fileWithMagicWord; }, entry)] as const); const wProcessed = wBundles.map(async([entry, wBundle]) => { if (!await wBundle) return; await incr.work(async (io) => { // Only the reachable resources need to be read and inserted into the bundle. // This is what Map is for const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle)); const views = await Promise.all(viewRefs.map(ref => io.readWork(ref))); // Client JS const scriptList = Object.entries(await io.readWork(wScriptMap)); const viewScriptsList = new Set(views.flatMap(view => view.clientRefs)); const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k)); // CSS const viewStyleKeys = views.map((view) => view.styleKey); const viewCssBundles = await Promise.all( viewStyleKeys.map((key) => io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)))); const styleList = Array.from(new Set(viewCssBundles)); // Replace the magic word const text = bytes.toString("utf-8").replace( new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => { i = Number(i); // Inline the styling data if (i === -2) { return JSON.stringify(styleList.map((cssText) => cssText)); } // Inline the script data if (i === -1) { return JSON.stringify(Object.fromEntries(neededScripts)); } // Reference an index into `styleList` return `${styleList.indexOf(viewCssBundles[i])}`; }, ); io.writeFile(basename, text); }); }) await Promise.all(wProcessed); } import * as esbuild from "esbuild"; import * as path from "node:path"; import process from "node:process"; import * as hot from "./hot.ts"; import { isIgnoredSource, markoViaBuildCache, projectRelativeResolution, virtualFiles, } from "./esbuild-support.ts"; import { Io, toAbs, toRel } from "./incremental.ts"; import * as css from "./css.ts"; import * as fs from "#sitegen/fs"; import * as mime from "#sitegen/mime"; import * as incr from "./incremental.ts"; import * as sg from "#sitegen";import type { PreparedView } from "./generate2.ts";import { meta } from "@/file-viewer/pages/file.cotyledon_speedbump.tsx";