sitegen/framework/bundle.ts

321 lines
9.2 KiB
TypeScript
Raw Normal View History

// 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([
2025-06-13 00:13:22 -07:00
...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"),
);
}
2025-06-13 00:13:22 -07:00
const clientPlugins: esbuild.Plugin[] = [
2025-06-15 13:11:21 -07:00
projectRelativeResolution(),
markoViaBuildCache(incr),
2025-06-13 00:13:22 -07:00
];
const bundle = await esbuild.build({
2025-06-21 16:04:57 -07:00
assetNames: "/asset/[hash]",
bundle: true,
chunkNames: "/js/c.[hash]",
entryNames: "/js/[name]",
entryPoints,
format: "esm",
2025-06-21 16:04:57 -07:00
jsx: "automatic",
jsxDev: dev,
jsxImportSource: "#ssr",
logLevel: "silent",
metafile: true,
minify: !dev,
2025-07-07 09:42:04 -07:00
outdir: "out!",
2025-06-09 21:13:51 -07:00
plugins: clientPlugins,
write: false,
2025-06-15 13:11:21 -07:00
define: {
"ASSERT": "console.assert",
2025-06-21 16:04:57 -07:00
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
2025-06-15 13:11:21 -07:00
},
2025-06-21 16:04:57 -07:00
}).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) =>
2025-06-15 13:11:21 -07:00
"/js/" +
path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace(
/\.client\.[tj]sx?/,
".js",
)
);
2025-06-15 13:11:21 -07:00
const { metafile, outputFiles } = bundle;
2025-06-08 15:12:04 -07:00
const promises: Promise<void>[] = [];
2025-06-15 13:11:21 -07:00
for (const file of outputFiles) {
2025-06-09 00:12:41 -07:00
const { text } = file;
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
2025-06-09 00:12:41 -07:00
const { inputs } = UNWRAP(metafile.outputs["out!" + route]);
2025-06-21 16:04:57 -07:00
const sources = Object.keys(inputs)
.filter((x) => !x.startsWith("<define:"));
2025-06-09 00:12:41 -07:00
// Register non-chunks as script entries.
const chunk = route.startsWith("/js/c.");
if (!chunk) {
2025-06-15 13:11:21 -07:00
const key = hot.getScriptId(path.resolve(sources[sources.length - 1]));
route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
incr.put({
2025-06-09 00:12:41 -07:00
sources,
2025-06-11 00:17:58 -07:00
kind: "script",
key,
value: text,
});
}
2025-06-09 00:12:41 -07:00
// Register chunks and public scripts as assets.
if (chunk || publicScriptRoutes.includes(route)) {
promises.push(incr.putAsset({
2025-06-09 00:12:41 -07:00
sources,
key: route,
body: text,
}));
}
}
2025-06-08 15:12:04 -07:00
await Promise.all(promises);
}
2025-06-13 00:13:22 -07:00
export type ServerPlatform = "node" | "passthru";
2025-06-09 21:13:51 -07:00
export async function bundleServerJavaScript(
2025-06-13 00:13:22 -07:00
incr: Incremental,
2025-06-09 21:13:51 -07:00
platform: ServerPlatform = "node",
) {
2025-06-13 00:13:22 -07:00
if (incr.hasArtifact("backendBundle", platform)) return;
// Comment
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
2025-06-09 21:13:51 -07:00
const viewSource = [
2025-06-13 00:13:22 -07:00
...Array.from(
incr.out.viewMetadata,
([, view], i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
2025-06-09 21:13:51 -07:00
),
2025-06-13 00:13:22 -07:00
`const styles = ${magicWord}[-2]`,
`export const scripts = ${magicWord}[-1]`,
2025-06-09 21:13:51 -07:00
"export const views = {",
2025-06-13 00:13:22 -07:00
...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")),
2025-06-09 21:13:51 -07:00
"}",
].join("\n");
2025-06-13 00:13:22 -07:00
// -- plugins --
2025-06-09 21:13:51 -07:00
const serverPlugins: esbuild.Plugin[] = [
virtualFiles({
"$views": viewSource,
}),
2025-06-10 01:13:59 -07:00
projectRelativeResolution(),
2025-06-15 13:11:21 -07:00
markoViaBuildCache(incr),
{
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,
}));
},
},
2025-06-10 01:13:59 -07:00
{
name: "mark css external",
setup(b) {
b.onResolve(
{ filter: /\.css$/ },
() => ({ path: ".", namespace: "dropped" }),
);
b.onLoad(
{ filter: /./, namespace: "dropped" },
() => ({ contents: "" }),
);
},
},
2025-06-09 21:13:51 -07:00
];
2025-06-15 13:11:21 -07:00
const pkg = await fs.readJson("package.json") as {
dependencies: Record<string, string>;
};
const { metafile, outputFiles } = await esbuild.build({
2025-06-08 15:12:04 -07:00
bundle: true,
2025-06-10 01:13:59 -07:00
chunkNames: "c.[hash]",
2025-06-13 00:13:22 -07:00
entryNames: "server",
2025-06-10 01:13:59 -07:00
entryPoints: [
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
],
2025-06-09 21:13:51 -07:00
platform: "node",
2025-06-08 15:12:04 -07:00
format: "esm",
2025-06-09 21:13:51 -07:00
minify: false,
2025-07-07 09:42:04 -07:00
outdir: "out!",
2025-06-09 21:13:51 -07:00
plugins: serverPlugins,
2025-06-08 15:12:04 -07:00
splitting: true,
2025-06-21 16:04:57 -07:00
logLevel: "silent",
2025-06-10 01:13:59 -07:00
write: false,
metafile: true,
jsx: "automatic",
jsxImportSource: "#ssr",
jsxDev: false,
2025-06-21 16:04:57 -07:00
define: {
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
},
2025-06-15 13:11:21 -07:00
external: Object.keys(pkg.dependencies)
.filter((x) => !x.startsWith("@paperclover")),
2025-06-10 01:13:59 -07:00
});
2025-06-13 00:13:22 -07:00
const files: Record<string, Buffer> = {};
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;
2025-06-11 00:17:58 -07:00
}
2025-06-13 00:13:22 -07:00
files[basename] = Buffer.from(output.contents);
}
incr.put({
kind: "backendBundle",
key: platform,
value: {
magicWord,
files,
fileWithMagicWord,
},
sources: Object.keys(metafile.inputs).filter((x) =>
2025-06-21 16:04:57 -07:00
!x.includes("<define:") &&
2025-06-13 00:13:22 -07:00
!x.startsWith("vfs:") &&
!x.startsWith("dropped:") &&
!x.includes("node_modules")
),
2025-06-08 15:12:04 -07:00
});
2025-06-10 01:13:59 -07:00
}
2025-06-13 00:13:22 -07:00
export async function finalizeServerJavaScript(
2025-06-10 01:13:59 -07:00
incr: Incremental,
2025-06-13 00:13:22 -07:00
platform: ServerPlatform,
2025-06-10 01:13:59 -07:00
) {
2025-06-13 00:13:22 -07:00
if (incr.hasArtifact("backendReplace", platform)) return;
const {
files,
fileWithMagicWord,
magicWord,
} = UNWRAP(incr.getArtifact("backendBundle", platform));
2025-06-10 01:13:59 -07:00
2025-06-13 00:13:22 -07:00
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),
2025-06-10 01:13:59 -07:00
);
2025-06-13 00:13:22 -07:00
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));
2025-06-10 01:13:59 -07:00
// Deduplicate styles
const styleList = Array.from(new Set(viewCssBundles));
2025-06-13 00:13:22 -07:00
// Replace the magic word
let text = files[fileWithMagicWord].toString("utf-8");
text = text.replace(
2025-06-27 19:40:19 -07:00
new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),
2025-06-13 00:13:22 -07:00
(_, 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])}`;
},
);
2025-06-10 01:13:59 -07:00
2025-06-13 00:13:22 -07:00
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),
});
}
2025-06-15 13:11:21 -07:00
function markoViaBuildCache(incr: Incremental): esbuild.Plugin {
return {
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) {
2025-06-21 16:04:57 -07:00
if (!fs.existsSync(file)) {
console.log(`File does not exist: ${file}`);
}
2025-06-15 13:11:21 -07:00
throw new Error("Marko file not in cache: " + file);
}
return ({
loader: "ts",
contents: cacheEntry.src,
resolveDir: path.dirname(file),
});
},
);
},
};
}
2025-06-21 16:04:57 -07:00
import * as esbuild from "esbuild";
2025-06-08 12:38:25 -07:00
import * as path from "node:path";
import process from "node:process";
2025-06-09 21:13:51 -07:00
import * as hot from "./hot.ts";
2025-06-21 16:04:57 -07:00
import { projectRelativeResolution, virtualFiles } from "./esbuild-support.ts";
import { Incremental } from "./incremental.ts";
2025-06-10 01:13:59 -07:00
import * as css from "./css.ts";
import * as fs from "#sitegen/fs";
2025-06-21 16:04:57 -07:00
import * as mime from "#sitegen/mime";