sitegen/framework/bundle.ts

383 lines
12 KiB
TypeScript
Raw Normal View History

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"),
);
}
2025-06-13 00:13:22 -07:00
const clientPlugins: esbuild.Plugin[] = [
2025-06-15 13:11:21 -07:00
projectRelativeResolution(),
markoViaBuildCache(),
2025-06-13 00:13:22 -07:00
];
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"),
);
2025-06-15 13:11:21 -07:00
const { metafile, outputFiles } = bundle;
const p = [];
p.push(trackEsbuild(io, metafile));
const scripts: Record<string, string> = {};
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("\\", "/");
const { entryPoint } = UNWRAP(metafile.outputs["out!" + route]);
2025-06-09 00:12:41 -07:00
// Register non-chunks as script entries.
const chunk = route.startsWith("/js/c.");
if (!chunk) {
const key = hot.getScriptId(toAbs(UNWRAP(entryPoint)));
2025-06-15 13:11:21 -07:00
route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
scripts[key] = text;
}
2025-06-09 00:12:41 -07:00
// 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;
2025-06-08 15:12:04 -07:00
}
2025-06-13 00:13:22 -07:00
export type ServerPlatform = "node" | "passthru";
export interface ServerSideOptions {
entries: string[];
viewItems: sg.FileItem[];
viewRefs: incr.Ref<PageOrView>[];
styleMap: Map<string, incr.Ref<string>>;
scriptMap: incr.Ref<Record<string, string>>;
platform: ServerPlatform;
metaTemplate: meta.Template;
}
export async function bundleServerJavaScript({
viewItems,
viewRefs,
styleMap,
scriptMap: wScriptMap,
entries,
platform,
metaTemplate,
}: ServerSideOptions) {
const regenKeys: Record<string, string[]> = {};
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!,
});
}
}
2025-06-13 00:13:22 -07:00
const wViewSource = incr.work(
async (
_,
{ viewItems, regenKeys, regenTtls }: {
viewItems: sg.FileItem[];
regenKeys: Record<string, string[]>;
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 },
);
2025-06-13 00:13:22 -07:00
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 },
);
2025-06-13 00:13:22 -07:00
const wBundles = entries.map((entry) =>
incr.work(async (io, entry) => {
const pkg = await io.readJson<{
dependencies: Record<string, string>;
}>("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)
);
2025-06-10 01:13:59 -07:00
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<string, incr.Ref> is for
const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));
const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref)));
2025-06-10 01:13:59 -07:00
// 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));
2025-06-10 01:13:59 -07:00
// 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));
2025-06-15 13:11:21 -07:00
}
// Reference an index into `styleList`
return `${styleList.indexOf(UNWRAP(viewCssBundles[i]))}`;
});
io.writeFile(basename, text);
});
});
await Promise.all(wProcessed);
2025-06-15 13:11:21 -07:00
}
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";
import {
isIgnoredSource,
markoViaBuildCache,
projectRelativeResolution,
virtualFiles,
} from "./esbuild-support.ts";
import { Io, toAbs } from "./incremental.ts";
import * as fs from "#sitegen/fs";
2025-06-21 16:04:57 -07:00
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";