sitegen/framework/bundle.ts

254 lines
7.2 KiB
TypeScript

// This file implements client-side bundling, mostly wrapping esbuild.
const clientPlugins: esbuild.Plugin[] = [
// There are currently no plugins needed by 'paperclover.net'
];
export async function bundleClientJavaScript(
referencedScripts: string[],
extraPublicScripts: string[],
incr: Incremental,
dev: boolean = false,
) {
const entryPoints = [
...new Set([
...referencedScripts,
...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 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/"],
});
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<void>[] = [];
// TODO: add a shared build hash to entrypoints, derived from all the chunk hashes.
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(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);
}
type ServerPlatform = "node" | "passthru";
export async function bundleServerJavaScript(
/** Has 'export default app;' */
_: string,
/** Views for dynamic loading */
viewEntryPoints: FileItem[],
platform: ServerPlatform = "node",
) {
const scriptMagic = "CLOVER_CLIENT_SCRIPTS_DEFINITION";
const viewSource = [
...viewEntryPoints.map((view, i) =>
`import * as view${i} from ${JSON.stringify(view.file)}`
),
`const styles = ${scriptMagic}[-2]`,
`export const scripts = ${scriptMagic}[-1]`,
"export const views = {",
...viewModules.flatMap(({ view, module }, i) => [
` ${JSON.stringify(view.id)}: {`,
` component: view${i}.default,`,
` meta: view${i}.meta,`,
` layout: ${
module.layout?.default ? `view${i}.layout?.default` : "undefined"
},`,
` theme: ${
module.layout?.theme
? `view${i}.layout?.theme`
: module.theme
? `view${i}.theme`
: "undefined"
},`,
` inlineCss: styles[${scriptMagic}[${i}]]`,
` },`,
]),
"}",
].join("\n");
const serverPlugins: esbuild.Plugin[] = [
virtualFiles({
"$views": viewSource,
}),
projectRelativeResolution(),
{
name: "marko via build cache",
setup(b) {
b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => ({
loader: "ts",
contents: hot.getSourceCode(file),
}));
},
},
{
name: "mark css external",
setup(b) {
b.onResolve(
{ filter: /\.css$/ },
() => ({ path: ".", namespace: "dropped" }),
);
b.onLoad(
{ filter: /./, namespace: "dropped" },
() => ({ contents: "" }),
);
},
},
];
const bundle = await esbuild.build({
bundle: true,
chunkNames: "c.[hash]",
entryNames: "[name]",
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,
});
const viewModules = viewEntryPoints.map((view) => {
const module = require(view.file);
if (!module.meta) {
throw new Error(`${view.file} is missing 'export const meta'`);
}
if (!module.default) {
throw new Error(`${view.file} is missing a default export.`);
}
return { module, view };
});
const viewData = viewModules.map(({ module, view }) => {
return {
id: view.id,
theme: module.theme,
cssImports: hot.getCssImports(view.file)
.concat("src/global.css")
.map((file) => path.resolve(file)),
clientRefs: hot.getClientScriptRefs(view.file),
};
});
return {
views: viewData,
bundle,
scriptMagic,
};
}
type Await<T> = T extends Promise<infer R> ? R : T;
export function finalizeServerJavaScript(
backend: Await<ReturnType<typeof bundleServerJavaScript>>,
viewCssBundles: css.Output[],
incr: Incremental,
) {
const { metafile, outputFiles } = backend.bundle;
// Only the reachable script files need to be inserted into the bundle.
const reachableScriptKeys = new Set(
backend.views.flatMap((view) => view.clientRefs),
);
const reachableScripts = Object.fromEntries(
Array.from(incr.out.script)
.filter(([k]) => reachableScriptKeys.has(k)),
);
// Deduplicate styles
const styleList = Array.from(new Set(viewCssBundles));
for (const output of outputFiles) {
const basename = output.path.replace(/^.*?!/, "");
const key = "out!" + basename.replaceAll("\\", "/");
// If this contains the generated "$views" file, then
// replace the IDs with the bundled results.
let text = output.text;
if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
text = text.replace(
/CLOVER_CLIENT_SCRIPTS_DEFINITION\[(-?\d)\]/gs,
(_, i) => {
i = Number(i);
// Inline the styling data
if (i === -2) {
return JSON.stringify(styleList.map((item) => item.text));
}
// 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])}`;
},
);
}
fs.writeMkdirSync(path.join(".clover/backend/" + basename), 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 type { FileItem } from "#sitegen";
import * as marko from "@marko/compiler";
import * as css from "./css.ts";
import * as fs from "./lib/fs.ts";