// This file implements client-side bundling, mostly wrapping esbuild. export async function bundleClientJavaScript( referencedScripts: string[], extraPublicScripts: string[], incr: Incremental, dev: boolean = false, ) { const entryPoints = [ ...new Set([ ...referencedScripts.map((file) => path.resolve(hot.projectSrc, file)), ...extraPublicScripts, ]), ]; 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[] = [ // There are currently no plugins needed by 'paperclover.net' ]; const bundle = await esbuild.build({ bundle: true, chunkNames: "/js/c.[hash]", entryNames: "/js/[name]", assetNames: "/asset/[hash]", entryPoints, format: "esm", minify: !dev, outdir: "/out!", plugins: clientPlugins, write: false, metafile: true, external: ["node_modules/"], jsx: "automatic", jsxImportSource: "#ssr", jsxDev: dev, }); if (bundle.errors.length || bundle.warnings.length) { throw new AggregateError( bundle.errors.concat(bundle.warnings), "JS bundle failed", ); } const publicScriptRoutes = extraPublicScripts.map((file) => path.basename(file).replace(/\.client\.[tj]sx?/, "") ); const { metafile } = bundle; const promises: Promise[] = []; for (const file of bundle.outputFiles) { const { text } = file; let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/"); const { inputs } = UNWRAP(metafile.outputs["out!" + route]); const sources = Object.keys(inputs); // Register non-chunks as script entries. const chunk = route.startsWith("/js/c."); if (!chunk) { const key = hot.getScriptId(path.resolve(sources[0])); route = "/js/" + key + ".js"; incr.put({ sources, kind: "script", key, value: text, }); } // Register chunks and public scripts as assets. if (chunk || publicScriptRoutes.includes(route)) { promises.push(incr.putAsset({ sources, key: route, body: text, })); } } await Promise.all(promises); } export type ServerPlatform = "node" | "passthru"; export async function bundleServerJavaScript( incr: Incremental, platform: ServerPlatform = "node", ) { if (incr.hasArtifact("backendBundle", platform)) return; // Comment const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_"); const viewSource = [ ...Array.from( incr.out.viewMetadata, ([, view], i) => `import * as view${i} from ${JSON.stringify(view.file)}`, ), `const styles = ${magicWord}[-2]`, `export const scripts = ${magicWord}[-1]`, "export const views = {", ...Array.from(incr.out.viewMetadata, ([key, view], i) => [ ` ${JSON.stringify(key)}: {`, ` component: view${i}.default,`, // ` meta: ${ // view.staticMeta ? JSON.stringify(view.staticMeta) : `view${i}.meta` // },`, ` meta: view${i}.meta,`, ` layout: ${view.hasLayout ? `view${i}.layout?.default` : "null"},`, ` inlineCss: styles[${magicWord}[${i}]]`, ` },`, ].join("\n")), "}", ].join("\n"); // -- plugins -- const serverPlugins: esbuild.Plugin[] = [ virtualFiles({ "$views": viewSource, }), projectRelativeResolution(), { name: "marko via build cache", setup(b) { b.onLoad( { filter: /\.marko$/ }, async ({ path: file }) => { const key = path.relative(hot.projectRoot, file) .replaceAll("\\", "/"); const cacheEntry = incr.out.serverMarko.get(key); if (!cacheEntry) { throw new Error("Marko file not in cache: " + file); } return ({ loader: "ts", contents: cacheEntry.src, resolveDir: path.dirname(file), }); }, ); }, }, { 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: "server", entryPoints: [ path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"), ], platform: "node", format: "esm", minify: false, outdir: "/out!", plugins: serverPlugins, splitting: true, write: false, metafile: true, jsx: "automatic", jsxImportSource: "#ssr", jsxDev: false, }); const files: Record = {}; let fileWithMagicWord: 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 (metafile.outputs[key].inputs["framework/lib/view.ts"]) { fileWithMagicWord = basename; } files[basename] = Buffer.from(output.contents); } incr.put({ kind: "backendBundle", key: platform, value: { magicWord, files, fileWithMagicWord, }, sources: Object.keys(metafile.inputs).filter((x) => !x.startsWith("vfs:") && !x.startsWith("dropped:") && !x.includes("node_modules") ), }); } export async function finalizeServerJavaScript( incr: Incremental, platform: ServerPlatform, ) { if (incr.hasArtifact("backendReplace", platform)) return; const { files, fileWithMagicWord, magicWord, } = UNWRAP(incr.getArtifact("backendBundle", platform)); if (!fileWithMagicWord) return; // Only the reachable resources need to be inserted into the bundle. const viewScriptsList = new Set( Array.from(incr.out.viewMetadata.values()) .flatMap((view) => view.clientRefs), ); const viewStyleKeys = Array.from(incr.out.viewMetadata.values()) .map((view) => css.styleKey(view.cssImports, view.theme)); const viewCssBundles = viewStyleKeys .map((key) => UNWRAP(incr.out.style.get(key), "Style key: " + key)); // Deduplicate styles const styleList = Array.from(new Set(viewCssBundles)); // Replace the magic word let text = files[fileWithMagicWord].toString("utf-8"); text = text.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(incr.out.script)); } // Reference an index into `styleList` return `${styleList.indexOf(viewCssBundles[i])}`; }, ); incr.put({ kind: "backendReplace", key: platform, sources: [ // Backend input code (includes view code) ...incr.sourcesFor("backendBundle", platform), // Script ...Array.from(viewScriptsList) .flatMap((key) => incr.sourcesFor("script", hot.getScriptId(key))), // Style ...viewStyleKeys.flatMap((key) => incr.sourcesFor("style", key)), ], value: Buffer.from(text), }); } import * as esbuild from "esbuild"; import * as path from "node:path"; import process from "node:process"; import * as hot from "./hot.ts"; import { banFiles, projectRelativeResolution, virtualFiles, } from "./esbuild-support.ts"; import { Incremental } from "./incremental.ts"; import * as css from "./css.ts"; import * as fs from "#sitegen/fs";