1async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
2 await Promise.all(
3 Object.keys(metafile.inputs)
4 .filter((file) => !isIgnoredSource(file))
5 .map((file) => io.trackFile(file)),
6 );
7}
8
9// This file implements client-side bundling, mostly wrapping esbuild.
10export async function bundleClientJavaScript(
11 io: Io,
12 {
13 clientRefs,
14 extraPublicScripts,
15 dev = false,
16 }: {
17 clientRefs: string[];
18 extraPublicScripts: string[];
19 dev: boolean;
20 },
21) {
22 const entryPoints = [
23 ...new Set(
24 [...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
25 ),
26 ];
27 if (entryPoints.length === 0) return {};
28 const invalidFiles = entryPoints.filter(
29 (file) => !file.match(/\.client\.[tj]sx?/),
30 );
31 if (invalidFiles.length > 0) {
32 const cwd = process.cwd();
33 throw new Error(
34 "All client-side scripts should be named like '.client.ts'. Exceptions: \n"
35 + invalidFiles.map((x) => path.join(cwd, x)).join("\n"),
36 );
37 }
38
39 const clientPlugins: esbuild.Plugin[] = [
40 markoViaBuildCache(),
41 {
42 name: "mark css external",
43 setup(b) {
44 b.onResolve({ filter: /\.css$/ }, () => ({
45 path: ".",
46 namespace: "dropped",
47 }));
48 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
49 contents: "",
50 }));
51 },
52 },
53 ];
54
55 using _ = io.start(`bundle ${entryPoints.length} files`);
56
57 const bundle = await esbuild
58 .build({
59 assetNames: "/asset/[hash]",
60 bundle: true,
61 chunkNames: "/js/c.[hash]",
62 entryNames: "/js/[name]",
63 entryPoints,
64 format: "esm",
65 jsx: "automatic",
66 jsxDev: dev,
67 jsxImportSource: "#jsx",
68 logLevel: "silent",
69 metafile: true,
70 minify: !dev,
71 outdir: "out!",
72 plugins: clientPlugins,
73 write: false,
74 external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"],
75 define: {
76 ASSERT: "console.assert",
77 },
78 })
79 .catch((err: any) => {
80 err.message = `Client ${err.message}`;
81 throw err;
82 });
83 if (bundle.errors.length || bundle.warnings.length) {
84 throw new AggregateError(
85 bundle.errors.concat(bundle.warnings),
86 "JS bundle failed",
87 );
88 }
89 const publicScriptRoutes = extraPublicScripts.map(
90 (file) =>
91 "/js/"
92 + path
93 .relative(hot.projectSrc, file)
94 .replaceAll("\\", "/")
95 .replace(/\.client\.[tj]sx?/, ".js"),
96 );
97 const { metafile, outputFiles } = bundle;
98 const p = [];
99 p.push(trackEsbuild(io, metafile));
100 const scripts: Record<string, string> = {};
101 for (const file of outputFiles) {
102 const { text } = file;
103 let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
104 const { entryPoint } = UNWRAP(metafile.outputs["out!" + route]);
105
106 // Register non-chunks as script entries.
107 const chunk = route.startsWith("/js/c.");
108 if (!chunk) {
109 const key = hot.getScriptId(toAbs(UNWRAP(entryPoint)));
110 route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
111 scripts[key] = text;
112 }
113 // Register chunks and public scripts as assets.
114 if (chunk || publicScriptRoutes.includes(route)) {
115 p.push(io.writeAsset({ pathname: route, buffer: text }));
116 }
117 }
118 await Promise.all(p);
119 return scripts;
120}
121
122export type ServerPlatform = "node" | "passthru";
123export interface ServerSideOptions {
124 progress: progress.Ref;
125 entries: string[];
126 viewItems: sg.FileItem[];
127 viewRefs: incr.Ref<PageOrView>[];
128 styleMap: Map<string, incr.Ref<string>>;
129 scriptMap: incr.Ref<Record<string, string>>;
130 platform: ServerPlatform;
131 metaTemplate: meta.Template;
132}
133export async function bundleServerJavaScript({
134 progress,
135 viewItems,
136 viewRefs,
137 styleMap,
138 scriptMap: wScriptMap,
139 entries,
140 platform,
141 metaTemplate,
142}: ServerSideOptions) {
143 const regenKeys: Record<string, string[]> = {};
144 const regenTtls: view.Ttl[] = [];
145 for (const ref of viewRefs) {
146 const value = UNWRAP(ref.value);
147 if (value.type === "page" && (value.regenerate?.tags?.length ?? 0) > 0) {
148 for (const tag of value.regenerate!.tags!) {
149 (regenKeys[tag] ??= []).push(`page:${value.id}`);
150 }
151 }
152 if (value.type === "page" && (value.regenerate?.seconds ?? 0) > 0) {
153 regenTtls.push({
154 key: `page:${value.id}` as view.Key,
155 seconds: value.regenerate!.seconds!,
156 });
157 }
158 }
159
160 const wViewSource = incr.work(
161 progress,
162 async (
163 _,
164 { viewItems, regenKeys, regenTtls }: {
165 viewItems: sg.FileItem[];
166 regenKeys: Record<string, string[]>;
167 regenTtls: view.Ttl[];
168 },
169 ) => {
170 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
171 return {
172 magicWord,
173 file: [
174 ...viewItems.map(
175 (view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
176 ),
177 `const styles = ${magicWord}[-2]`,
178 `export const scripts = ${magicWord}[-1]`,
179 "export const views = {",
180 ...viewItems.map((view, i) =>
181 [
182 ` ${JSON.stringify(view.id)}: {`,
183 ` component: view${i}.default,`,
184 ` meta: view${i}.meta,`,
185 ` layout: view${i}.layout?.default ?? null,`,
186 ` inlineCss: styles[${magicWord}[${i}]]`,
187 ` },`,
188 ].join("\n")
189 ),
190 "}",
191 `export const regenTags = ${JSON.stringify(regenKeys)};`,
192 `export const regenTtls = ${JSON.stringify(regenTtls)};`,
193 `export const metaTemplate = {`,
194 ` base: new URL(${JSON.stringify(metaTemplate.base)}),`,
195 ...Object.entries(metaTemplate)
196 .filter(([k]) => k !== "base")
197 .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`),
198 `};`,
199 ].join("\n"),
200 };
201 },
202 { viewItems, regenKeys, regenTtls },
203 );
204
205 await incr.work(
206 progress,
207 async (io, { regenKeys, viewItems }) => {
208 io.writeFile(
209 "../ts/view.d.ts",
210 [
211 "export interface RegisteredViews {",
212 ...viewItems
213 .filter((view) => !view.id.startsWith("page:"))
214 .map(
215 (view) =>
216 ` ${JSON.stringify(view.id)}: `
217 + `typeof import(${JSON.stringify(path.relative(".clover/ts", toAbs(view.file)))}),`,
218 ),
219 "}",
220 "export type RegenKey = "
221 + (regenKeys.map((key) => JSON.stringify(key)).join(" | ")
222 || "never"),
223 ].join("\n"),
224 );
225 },
226 { regenKeys: Object.keys(regenKeys), viewItems },
227 );
228
229 const wBundles = entries.map((entry) => {
230 const node = progress.start(incr.toRel(entry), { passive: true });
231 return incr.work(node, async (io, entry) => {
232 using _ = io.start("esbuild");
233
234 const pkg = await io.readJson<{
235 dependencies: Record<string, string>;
236 }>("package.json");
237
238 let magicWord = null as string | null;
239 // -- plugins --
240 const serverPlugins: esbuild.Plugin[] = [
241 virtualFiles({
242 // only add dependency when imported.
243 $views: async () => {
244 const view = await io.readWork(wViewSource);
245 ({ magicWord } = view);
246 return view.file;
247 },
248 }),
249 markoViaBuildCache(),
250 {
251 name: "replace client references",
252 setup(b) {
253 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
254 contents: hot.resolveClientRefs(
255 await fs.readFile(file, "utf-8"),
256 file,
257 ).code,
258 loader: path.extname(file).slice(1) as esbuild.Loader,
259 }));
260 },
261 },
262 {
263 name: "mark css external",
264 setup(b) {
265 b.onResolve({ filter: /\.css$/ }, () => ({
266 path: ".",
267 namespace: "dropped",
268 }));
269 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
270 contents: "",
271 }));
272 },
273 },
274 ];
275
276 const { metafile, outputFiles } = await esbuild.build({
277 bundle: true,
278 chunkNames: "c.[hash]",
279 entryNames: path.basename(entry, path.extname(entry)),
280 entryPoints: [
281 path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
282 ],
283 platform: "node",
284 format: "esm",
285 minify: false,
286 outdir: "out!",
287 plugins: serverPlugins,
288 splitting: true,
289 logLevel: "silent",
290 write: false,
291 metafile: true,
292 jsx: "automatic",
293 jsxImportSource: "#jsx",
294 jsxDev: false,
295 define: {
296 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
297 },
298 external: Object.keys(pkg.dependencies).filter(
299 (x) => x !== "@clo/lib",
300 ),
301 });
302 await trackEsbuild(io, metafile);
303
304 let fileWithMagicWord: {
305 bytes: Buffer;
306 basename: string;
307 magicWord: string;
308 } | null = null;
309 for (const output of outputFiles) {
310 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
311 const key = "out!/" + basename.replaceAll("\\", "/");
312 // If this contains the generated "$views" file, then
313 // mark this file as the one for replacement. Because
314 // `splitting` is `true`, esbuild will not emit this
315 // file in more than one chunk.
316 if (
317 magicWord
318 && UNWRAP(metafile.outputs[key]).inputs["framework/lib/view.ts"]
319 ) {
320 ASSERT(!fileWithMagicWord);
321 fileWithMagicWord = {
322 basename,
323 bytes: Buffer.from(output.contents),
324 magicWord,
325 };
326 } else {
327 io.writeFile(basename, Buffer.from(output.contents));
328 }
329 }
330 return fileWithMagicWord;
331 }, entry);
332 });
333
334 const wProcessed = wBundles.map(async (wBundle) => {
335 if (!(await wBundle)) return;
336 await incr.work(wBundle.progress, async (io) => {
337 using _ = io.start("postprocess");
338
339 // Only the reachable resources need to be read and inserted into the bundle.
340 // This is what Map<string, incr.Ref> is for
341 const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));
342 const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref)));
343
344 // Client JS
345 const scriptList = Object.entries(await io.readWork(wScriptMap));
346 const viewScriptsList = new Set(views.flatMap((view) => view.clientRefs));
347 const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
348
349 // CSS
350 const viewStyleKeys = views.map((view) => view.styleKey);
351 const viewCssBundles = await Promise.all(
352 viewStyleKeys.map((key) => io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key))),
353 );
354 const styleList = Array.from(new Set(viewCssBundles));
355
356 // Replace the magic word
357 const text = bytes
358 .toString("utf-8")
359 .replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
360 i = Number(i);
361 // Inline the styling data
362 if (i === -2) {
363 return JSON.stringify(styleList.map((cssText) => cssText));
364 }
365 // Inline the script data
366 if (i === -1) {
367 return JSON.stringify(Object.fromEntries(neededScripts));
368 }
369 // Reference an index into `styleList`
370 return `${styleList.indexOf(UNWRAP(viewCssBundles[i]))}`;
371 });
372
373 io.writeFile(basename, text);
374 });
375 });
376
377 await Promise.all(wProcessed);
378}
379
380import * as sg from "#sitegen";
381import * as fs from "#sitegen/fs";
382import type * as view from "#sitegen/view";
383import { ASSERT, UNWRAP } from "@clo/lib/assert";
384import * as meta from "@clo/lib/meta";
385import * as progress from "@clo/lib/progress";
386import * as esbuild from "esbuild";
387import * as path from "node:path";
388import process from "node:process";
389import { isIgnoredSource, markoViaBuildCache, virtualFiles } from "./esbuild-support.ts";
390import type { PageOrView } from "./generate.ts";
391import * as hot from "./hot.ts";
392import { Io, toAbs } from "./incremental.ts";
393import * as incr from "./incremental.ts";