2025-08-02 17:31:58 -07:00
|
|
|
async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
|
2025-08-02 19:22:07 -07:00
|
|
|
await Promise.all(
|
|
|
|
Object.keys(metafile.inputs)
|
|
|
|
.filter((file) => !isIgnoredSource(file))
|
|
|
|
.map((file) => io.trackFile(file)),
|
|
|
|
);
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
|
|
|
|
2025-06-06 23:38:02 -07:00
|
|
|
// This file implements client-side bundling, mostly wrapping esbuild.
|
|
|
|
export async function bundleClientJavaScript(
|
2025-08-02 17:31:58 -07:00
|
|
|
io: Io,
|
2025-08-02 19:22:07 -07:00
|
|
|
{
|
|
|
|
clientRefs,
|
|
|
|
extraPublicScripts,
|
|
|
|
dev = false,
|
|
|
|
}: {
|
2025-08-02 17:31:58 -07:00
|
|
|
clientRefs: string[];
|
|
|
|
extraPublicScripts: string[];
|
|
|
|
dev: boolean;
|
|
|
|
},
|
2025-06-06 23:38:02 -07:00
|
|
|
) {
|
|
|
|
const entryPoints = [
|
2025-08-02 19:22:07 -07:00
|
|
|
...new Set(
|
|
|
|
[...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
|
|
|
|
),
|
2025-06-06 23:38:02 -07:00
|
|
|
];
|
2025-08-02 17:31:58 -07:00
|
|
|
if (entryPoints.length === 0) return {};
|
2025-08-02 19:22:07 -07:00
|
|
|
const invalidFiles = entryPoints.filter(
|
|
|
|
(file) => !file.match(/\.client\.[tj]sx?/),
|
|
|
|
);
|
2025-06-06 23:38:02 -07:00
|
|
|
if (invalidFiles.length > 0) {
|
|
|
|
const cwd = process.cwd();
|
|
|
|
throw new Error(
|
2025-06-10 20:06:32 -07:00
|
|
|
"All client-side scripts should be named like '.client.ts'. Exceptions: \n" +
|
|
|
|
invalidFiles.map((x) => path.join(cwd, x)).join("\n"),
|
2025-06-06 23:38:02 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-06-13 00:13:22 -07:00
|
|
|
const clientPlugins: esbuild.Plugin[] = [
|
2025-06-15 13:11:21 -07:00
|
|
|
projectRelativeResolution(),
|
2025-08-02 17:31:58 -07:00
|
|
|
markoViaBuildCache(),
|
2025-06-13 00:13:22 -07:00
|
|
|
];
|
|
|
|
|
2025-08-02 19:22:07 -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;
|
|
|
|
});
|
2025-06-06 23:38:02 -07:00
|
|
|
if (bundle.errors.length || bundle.warnings.length) {
|
|
|
|
throw new AggregateError(
|
|
|
|
bundle.errors.concat(bundle.warnings),
|
|
|
|
"JS bundle failed",
|
|
|
|
);
|
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
const publicScriptRoutes = extraPublicScripts.map(
|
|
|
|
(file) =>
|
|
|
|
"/js/" +
|
|
|
|
path
|
|
|
|
.relative(hot.projectSrc, file)
|
|
|
|
.replaceAll("\\", "/")
|
|
|
|
.replace(/\.client\.[tj]sx?/, ".js"),
|
2025-06-06 23:38:02 -07:00
|
|
|
);
|
2025-06-15 13:11:21 -07:00
|
|
|
const { metafile, outputFiles } = bundle;
|
2025-08-02 19:22:07 -07:00
|
|
|
const p = [];
|
2025-08-02 17:31:58 -07:00
|
|
|
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;
|
2025-06-06 23:38:02 -07:00
|
|
|
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
|
2025-06-09 00:12:41 -07:00
|
|
|
const { inputs } = UNWRAP(metafile.outputs["out!" + route]);
|
2025-08-02 17:31:58 -07:00
|
|
|
const sources = Object.keys(inputs).filter((x) => !isIgnoredSource(x));
|
2025-06-09 00:12:41 -07:00
|
|
|
|
2025-06-06 23:38:02 -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");
|
2025-08-02 17:31:58 -07:00
|
|
|
scripts[key] = text;
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
2025-06-09 00:12:41 -07:00
|
|
|
// Register chunks and public scripts as assets.
|
2025-06-06 23:38:02 -07:00
|
|
|
if (chunk || publicScriptRoutes.includes(route)) {
|
2025-08-02 17:31:58 -07:00
|
|
|
p.push(io.writeAsset(route, text));
|
2025-06-06 23:38:02 -07:00
|
|
|
}
|
|
|
|
}
|
2025-08-02 17:31:58 -07:00
|
|
|
await Promise.all(p);
|
|
|
|
return scripts;
|
2025-06-08 15:12:04 -07:00
|
|
|
}
|
2025-06-06 23:38:02 -07:00
|
|
|
|
2025-06-13 00:13:22 -07:00
|
|
|
export type ServerPlatform = "node" | "passthru";
|
2025-08-02 17:31:58 -07:00
|
|
|
export interface ServerSideOptions {
|
2025-08-02 19:22:07 -07:00
|
|
|
entries: string[];
|
|
|
|
viewItems: sg.FileItem[];
|
2025-08-02 21:31:56 -07:00
|
|
|
viewRefs: incr.Ref<PageOrView>[];
|
2025-08-02 17:31:58 -07:00
|
|
|
styleMap: Map<string, incr.Ref<string>>;
|
|
|
|
scriptMap: incr.Ref<Record<string, string>>;
|
2025-08-02 19:22:07 -07:00
|
|
|
platform: ServerPlatform;
|
2025-08-02 17:31:58 -07:00
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
export async function bundleServerJavaScript({
|
|
|
|
viewItems,
|
|
|
|
viewRefs,
|
|
|
|
styleMap,
|
|
|
|
scriptMap: wScriptMap,
|
|
|
|
entries,
|
|
|
|
platform,
|
|
|
|
}: ServerSideOptions) {
|
2025-08-02 17:31:58 -07:00
|
|
|
const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {
|
|
|
|
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
|
|
|
|
return {
|
|
|
|
magicWord,
|
|
|
|
file: [
|
2025-08-02 19:22:07 -07:00
|
|
|
...viewItems.map(
|
|
|
|
(view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
|
|
|
|
),
|
2025-08-02 17:31:58 -07:00
|
|
|
`const styles = ${magicWord}[-2]`,
|
|
|
|
`export const scripts = ${magicWord}[-1]`,
|
|
|
|
"export const views = {",
|
2025-08-02 19:22:07 -07:00
|
|
|
...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"),
|
|
|
|
),
|
2025-08-02 17:31:58 -07:00
|
|
|
"}",
|
2025-08-02 19:22:07 -07:00
|
|
|
].join("\n"),
|
2025-08-02 17:31:58 -07:00
|
|
|
};
|
2025-08-02 19:22:07 -07:00
|
|
|
}, viewItems);
|
2025-06-13 00:13:22 -07:00
|
|
|
|
2025-08-02 21:31:56 -07:00
|
|
|
await incr.work(async (io) => {
|
|
|
|
io.writeFile(
|
|
|
|
"../ts/view.d.ts",
|
2025-08-02 19:22:07 -07:00
|
|
|
[
|
2025-08-02 21:31:56 -07:00
|
|
|
"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)))}),`,
|
|
|
|
),
|
|
|
|
"}",
|
|
|
|
].join("\n"),
|
|
|
|
);
|
|
|
|
});
|
2025-06-13 00:13:22 -07:00
|
|
|
|
2025-08-02 21:31:56 -07:00
|
|
|
const wBundles = entries.map((entry) =>
|
|
|
|
incr.work(async (io, entry) => {
|
|
|
|
const pkg = await io.readJson<{
|
|
|
|
dependencies: Record<string, string>;
|
|
|
|
}>("package.json");
|
2025-06-13 00:13:22 -07:00
|
|
|
|
2025-08-02 21:31:56 -07:00
|
|
|
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: "",
|
|
|
|
}));
|
|
|
|
},
|
|
|
|
},
|
|
|
|
];
|
2025-06-13 00:13:22 -07:00
|
|
|
|
2025-08-02 21:31:56 -07:00
|
|
|
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 &&
|
|
|
|
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-08-02 19:22:07 -07:00
|
|
|
);
|
2025-06-10 01:13:59 -07:00
|
|
|
|
2025-08-02 21:31:56 -07:00
|
|
|
const wProcessed = wBundles.map(async (wBundle) => {
|
2025-08-02 19:22:07 -07:00
|
|
|
if (!(await wBundle)) return;
|
2025-08-02 17:31:58 -07:00
|
|
|
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));
|
2025-08-02 19:22:07 -07:00
|
|
|
const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref)));
|
2025-06-10 01:13:59 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// Client JS
|
|
|
|
const scriptList = Object.entries(await io.readWork(wScriptMap));
|
2025-08-02 19:22:07 -07:00
|
|
|
const viewScriptsList = new Set(views.flatMap((view) => view.clientRefs));
|
2025-08-02 17:31:58 -07:00
|
|
|
const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
|
2025-06-10 01:13:59 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// CSS
|
|
|
|
const viewStyleKeys = views.map((view) => view.styleKey);
|
|
|
|
const viewCssBundles = await Promise.all(
|
2025-08-02 19:22:07 -07:00
|
|
|
viewStyleKeys.map((key) =>
|
|
|
|
io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)),
|
|
|
|
),
|
|
|
|
);
|
2025-08-02 17:31:58 -07:00
|
|
|
const styleList = Array.from(new Set(viewCssBundles));
|
2025-06-06 23:38:02 -07:00
|
|
|
|
2025-08-02 17:31:58 -07:00
|
|
|
// Replace the magic word
|
2025-08-02 19:22:07 -07:00
|
|
|
const text = bytes
|
|
|
|
.toString("utf-8")
|
|
|
|
.replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
|
2025-08-02 17:31:58 -07:00
|
|
|
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
|
|
|
}
|
2025-08-02 17:31:58 -07:00
|
|
|
// Reference an index into `styleList`
|
|
|
|
return `${styleList.indexOf(viewCssBundles[i])}`;
|
2025-08-02 19:22:07 -07:00
|
|
|
});
|
2025-08-02 17:31:58 -07:00
|
|
|
|
|
|
|
io.writeFile(basename, text);
|
|
|
|
});
|
2025-08-02 19:22:07 -07:00
|
|
|
});
|
2025-08-02 17:31:58 -07:00
|
|
|
|
|
|
|
await Promise.all(wProcessed);
|
2025-06-15 13:11:21 -07:00
|
|
|
}
|
2025-06-21 16:04:57 -07:00
|
|
|
|
2025-06-06 23:38:02 -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-08-02 17:31:58 -07:00
|
|
|
import {
|
|
|
|
isIgnoredSource,
|
|
|
|
markoViaBuildCache,
|
|
|
|
projectRelativeResolution,
|
|
|
|
virtualFiles,
|
|
|
|
} from "./esbuild-support.ts";
|
2025-08-02 19:22:07 -07:00
|
|
|
import { Io, toAbs } from "./incremental.ts";
|
2025-06-15 11:35:28 -07:00
|
|
|
import * as fs from "#sitegen/fs";
|
2025-06-21 16:04:57 -07:00
|
|
|
import * as mime from "#sitegen/mime";
|
2025-08-02 17:31:58 -07:00
|
|
|
import * as incr from "./incremental.ts";
|
2025-08-02 19:22:07 -07:00
|
|
|
import * as sg from "#sitegen";
|
2025-08-02 21:31:56 -07:00
|
|
|
import type { PageOrView } from "./generate.ts";
|