1// This file implements client-side bundling, mostly wrapping esbuild.
2
3/**
4 * bundles every client script, given by its path within `src`. `scripts` are
5 * inlined into the pages that include them, by script id. `assets` are served
6 * by pathname: the chunks scripts share, and the `public` scripts, which
7 * pages load by url.
8 */
9export async function bundleClientJavaScript(
10 b: incremental.Build,
11 { clientRefs, publicScripts }: { clientRefs: string[]; publicScripts: string[] },
12) {
13 const toAbs = (id: string) => path.join(projectSrc, id);
14 const entryPoints = [...new Set([...clientRefs, ...publicScripts])].map(toAbs);
15 if (entryPoints.length === 0) return { scripts: {}, assets: {} };
16 const invalidFiles = entryPoints.filter(
17 (file) => !file.match(/\.client\.[tj]sx?/),
18 );
19 if (invalidFiles.length > 0) {
20 throw new Error(
21 "All client-side scripts should be named like '.client.ts'. Exceptions: \n"
22 + invalidFiles.join("\n"),
23 );
24 }
25
26 const clientPlugins: esbuild.Plugin[] = [
27 {
28 name: "marko",
29 setup(build) {
30 // the same code the server runs, which renders html in the browser too
31 build.onLoad({ filter: /\.marko$|\.mdo$/ }, async ({ path: file }) => {
32 const compiled = loadMarko({ file, source: await fs.readFile(file, "utf-8") });
33 return { loader: "ts", contents: compiled.content, resolveDir: path.dirname(file) };
34 });
35 },
36 },
37 {
38 name: "mark css external",
39 setup(build) {
40 build.onResolve({ filter: /\.css$/ }, () => ({
41 path: ".",
42 namespace: "dropped",
43 }));
44 build.onLoad({ filter: /./, namespace: "dropped" }, () => ({
45 contents: "",
46 }));
47 },
48 },
49 ];
50
51 using _ = b.start(`bundle ${entryPoints.length} files`);
52
53 const bundle = await esbuild
54 .build({
55 assetNames: "/asset/[hash]",
56 bundle: true,
57 chunkNames: "/js/c.[hash]",
58 entryNames: "/js/[name]",
59 entryPoints,
60 format: "esm",
61 logLevel: "silent",
62 metafile: true,
63 minify: true,
64 outdir: "out!",
65 plugins: clientPlugins,
66 write: false,
67 external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"],
68 define: {
69 ASSERT: "console.assert",
70 },
71 })
72 .catch((err: any) => {
73 err.message = `Client ${err.message}`;
74 throw err;
75 });
76 if (bundle.errors.length || bundle.warnings.length) {
77 throw new AggregateError(
78 bundle.errors.concat(bundle.warnings),
79 "JS bundle failed",
80 );
81 }
82 const { metafile, outputFiles } = bundle;
83
84 // everything esbuild read is an input. a package's package.json stands in
85 // for its files, since installing another version changes it
86 const packageJson = (file: string) => {
87 const at = file.lastIndexOf("node_modules/") + "node_modules/".length;
88 const [first, second] = file.slice(at).split("/");
89 return file.slice(0, at) + (first!.startsWith("@") ? `${first}/${second}` : first) + "/package.json";
90 };
91 const inputs = new Set(
92 Object.keys(metafile.inputs)
93 .filter((file) => !file.includes(":"))
94 .map((file) => file.includes("node_modules/") ? packageJson(file) : file),
95 );
96 await Promise.all(Array.from(inputs, (file) => b.readFile(file).hash()));
97
98 const publicRoutes = publicScripts.map((id) => "/js/" + id.replace(/\.client\.[tj]sx?/, ".js"));
99 const scripts: Record<string, string> = {};
100 const assets: Record<string, string> = {};
101 for (const file of outputFiles) {
102 let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
103 const { entryPoint } = UNWRAP(metafile.outputs["out!" + route]);
104
105 // Register non-chunks as script entries.
106 const chunk = route.startsWith("/js/c.");
107 if (!chunk) {
108 const key = getScriptId(path.resolve(UNWRAP(entryPoint)));
109 route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
110 scripts[key] = file.text;
111 }
112 // Register chunks and public scripts as assets.
113 if (chunk || publicRoutes.includes(route)) assets[route] = file.text;
114 }
115 return { scripts, assets };
116}
117
118import { UNWRAP } from "@clo/lib/assert";
119import type * as incremental from "@clo/lib/incremental";
120import * as esbuild from "esbuild";
121import * as fs from "node:fs/promises";
122import * as path from "node:path";
123import loadMarko, { getScriptId, projectSrc } from "./marko.ts";