| 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 | */ |
| 9 | export 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 | |
| 118 | import { UNWRAP } from "@clo/lib/assert"; |
| 119 | import type * as incremental from "@clo/lib/incremental"; |
| 120 | import * as esbuild from "esbuild"; |
| 121 | import * as fs from "node:fs/promises"; |
| 122 | import * as path from "node:path"; |
| 123 | import loadMarko, { getScriptId, projectSrc } from "./marko.ts"; |