| 1 | async 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. |
| 10 | export 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 | |
| 122 | export type ServerPlatform = "node" | "passthru"; |
| 123 | export 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 | } |
| 133 | export 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 | |
| 380 | import * as sg from "#sitegen"; |
| 381 | import * as fs from "#sitegen/fs"; |
| 382 | import type * as view from "#sitegen/view"; |
| 383 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
| 384 | import * as meta from "@clo/lib/meta"; |
| 385 | import * as progress from "@clo/lib/progress"; |
| 386 | import * as esbuild from "esbuild"; |
| 387 | import * as path from "node:path"; |
| 388 | import process from "node:process"; |
| 389 | import { isIgnoredSource, markoViaBuildCache, virtualFiles } from "./esbuild-support.ts"; |
| 390 | import type { PageOrView } from "./generate.ts"; |
| 391 | import * as hot from "./hot.ts"; |
| 392 | import { Io, toAbs } from "./incremental.ts"; |
| 393 | import * as incr from "./incremental.ts"; |