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: "#engine", 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 { entryPoint } = UNWRAP(metafile.outputs["out!" + route]); // Register non-chunks as script entries. const chunk = route.startsWith("/js/c."); if (!chunk) { const key = hot.getScriptId(toAbs(UNWRAP(entryPoint))); 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({ pathname: route, buffer: 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; metaTemplate: meta.Template; } export async function bundleServerJavaScript({ viewItems, viewRefs, styleMap, scriptMap: wScriptMap, entries, platform, metaTemplate, }: ServerSideOptions) { const regenKeys: Record = {}; const regenTtls: view.Ttl[] = []; for (const ref of viewRefs) { const value = UNWRAP(ref.value); if (value.type === "page" && (value.regenerate?.tags?.length ?? 0) > 0) { for (const tag of value.regenerate!.tags!) { (regenKeys[tag] ??= []).push(`page:${value.id}`); } } if (value.type === "page" && (value.regenerate?.seconds ?? 0) > 0) { regenTtls.push({ key: `page:${value.id}` as view.Key, seconds: value.regenerate!.seconds!, }); } } const wViewSource = incr.work( async ( _, { viewItems, regenKeys, regenTtls }: { viewItems: sg.FileItem[]; regenKeys: Record; regenTtls: view.Ttl[]; }, ) => { 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") ), "}", `export const regenTags = ${JSON.stringify(regenKeys)};`, `export const regenTtls = ${JSON.stringify(regenTtls)};`, `export const metaTemplate = {`, ` base: new URL(${JSON.stringify(metaTemplate.base)}),`, ...Object.entries(metaTemplate) .filter(([k]) => k !== 'base') .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`), `};`, ].join("\n"), }; }, { viewItems, regenKeys, regenTtls }, ); await incr.work( async (io, { regenKeys, viewItems }) => { io.writeFile( "../ts/view.d.ts", [ "export interface RegisteredViews {", ...viewItems .filter((view) => !view.id.startsWith("page:")) .map( (view) => ` ${JSON.stringify(view.id)}: ` + `typeof import(${ JSON.stringify(path.relative(".clover/ts", toAbs(view.file))) }),`, ), "}", "export type RegenKey = " + (regenKeys.map((key) => JSON.stringify(key)).join(" | ") || "never"), ].join("\n"), ); }, { regenKeys: Object.keys(regenKeys), viewItems }, ); const wBundles = entries.map((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 } = 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: "#engine", 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 && UNWRAP(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) ); const wProcessed = wBundles.map(async (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(UNWRAP(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 } from "./incremental.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 { PageOrView } from "./generate.ts"; import type * as view from "#sitegen/view"; import * as meta from "#sitegen/meta";