build
uses node:path
Build the app as its name currently resolves, into .tcm/apps/assetsDir, served under /_app/assets/), compressed. A build is a pure function of the hash,
so an existing one is reused (if this tcm made it: what tcm puts beside the app is part of it). Its browser code is a few chunks (chunkGroup) that import each other through an
import map in the page, so one that didn't change keeps its URL, and stays in the browser's cache, across deploys.
An app with a pwa gets its manifest, its images and a service worker that keeps the build (sw.js).
Builds of one app at once don't clash: each is made in a directory of its own (#de4335sb) and put in
place whole (publishBuild); the first there stays, and the others drop theirs, the same build. What it uses
is leased while it's made, so pruning leaves it; lease (the caller's) goes on holding the build once it's
returned. Making one takes a slot of the machine's (withSlot), shared with every tcm's work.
import { withSlot } from "#codebase/tcmts/cli/budget"; import type { Codebase } from "#codebase/tcmts/cli/codebase"; import { sentByReference } from "#codebase/tcmts/cli/flight"; import { bold, dim, green, yellow } from "#codebase/tcmts/cli/style"; import { appsDir, buildWorkDir, cacheableComponents, CHUNK_GROUPS, chunkGroup, clearLeftovers, keepAssets, lazyChunks, leaseBuilds, manifestFile, ownId, planApp, publishBuild, pwaAssets, reportPlan, serviceWorkerSource, SSR_DIR, tcmPlugin, withImportMap, withPwa, withStylesheetFiles, writeBuildRoot, } from "#codebase/tcmts/cli/web"; import type { BuildLease, BuildManifest } from "#codebase/tcmts/cli/web"; import { ownEffects } from "#codebase/tcmts/core/effects"; import { shortHash } from "#codebase/tcmts/core/hash"; import fs from "node:fs"; import path from "node:path"; export async function build( cb: Codebase, appName: string, opts: { force?: boolean; quiet?: boolean; lease?: BuildLease } = {}, ): Promise<BuildManifest> { const state = planApp(cb, appName); if (state.plan.errors.length) { throw new Error( `${appName} can't run in the browser yet:\n ${state.plan.errors.join("\n ")}`, ); } const outDir = path.join(appsDir(cb), state.key); // a build is of the app and of what tcm puts beside it in the browser (its runtime, the page's script): one another // tcm made is made again const hashOf = (f: unknown) => (f as Record<symbol, string | undefined>)[Symbol.for("tcm.hash")]; const tcm = hashOf(build); const work = buildWorkDir(cb); clearLeftovers(work); // (held before it's looked for: pruning that comes after leaves it, and pruning before has finished with it) const lease = opts.lease ?? leaseBuilds(work); const leased = lease.assets; lease.hold({ builds: [...lease.builds, state.key] }); const root = path.join(work, `${state.key}.${ownId()}`); try { if (!opts.force) { const made = (() => { try { return JSON.parse(fs.readFileSync(manifestFile(cb, state.key), "utf8")) as BuildManifest; } catch { return undefined; } })(); if (made && (!tcm || made.tcm === tcm)) return made; } return await withSlot(`app build of ${cb.lay.root} on ${cb.store.branch().name}`, () => makeBuild(), ); } finally { fs.rmSync(root, { recursive: true, force: true }); if (opts.lease) opts.lease.hold({ assets: leased }); else lease.release(); } async function makeBuild(): Promise<BuildManifest> { const { build: viteBuild } = await import("vite"); writeBuildRoot(cb, root, appName, state); // the build, put together here and then put in its place const staged = path.join(root, "app"); const out = path.join(root, "out"); const lazy = lazyChunks(); // (the stylesheets its code only links to, which the page names the files of) const pageFiles = new Map<string, string>(); await viteBuild({ configFile: false, root, base: "/_app/", logLevel: "warn", plugins: [tcmPlugin(cb, () => state.plan, undefined, pageFiles), lazy.plugin], build: { outDir: out, emptyOutDir: true, // (`rolldownOptions` drops the chunk groups) rollupOptions: { experimental: { chunkImportMap: { baseUrl: "/_app/", fileName: "importmap.json" } }, // (a group takes what its modules use, unless a group before it took that: packages go first) output: { codeSplitting: { groups: CHUNK_GROUPS.map((name, i) => ({ name, // (what only an import() reaches is split out of them: `lazyChunks`) test: (id: string) => chunkGroup(id) === name && !lazy.lazyOnly.has(id), priority: CHUNK_GROUPS.length - i, })), }, }, }, }, }); fs.mkdirSync(staged, { recursive: true }); // (held before they're shared: pruning leaves them until the build's manifest names them) const built = path.join(out, "assets"); lease.hold({ assets: [...lease.assets, ...(fs.existsSync(built) ? fs.readdirSync(built) : [])], }); const assets = keepAssets(cb, out); const map = path.join(out, "importmap.json"); let html = fs.readFileSync(path.join(out, "index.html"), "utf8"); if (fs.existsSync(map)) html = withImportMap(html, fs.readFileSync(map, "utf8")); html = withStylesheetFiles(html, pageFiles); let pwa: { offlineFirst: boolean; files: number; warnings: string[] } | undefined; if (state.pwa) { const made = await pwaAssets(cb, state.pwa, (f) => lease.hold({ assets: [...lease.assets, f] }), ); html = withPwa(html, made.head, made.title); assets.push(...made.files.filter((f) => !assets.includes(f))); // an app that calls no server opens from its cache, online or not const offlineFirst = !state.plan.endpoints.size; const files = ["./", ...assets.map((f) => `./_app/assets/${f}`)]; fs.writeFileSync( path.join(staged, "sw.js"), serviceWorkerSource(state.key, files, offlineFirst), ); pwa = { offlineFirst, files: files.length, warnings: made.warnings }; } fs.writeFileSync(path.join(staged, "index.html"), html); if (state.react) { // the first paint, rendered by `tcm start`: one bundle with React in it, kept out of the served assets await viteBuild({ configFile: false, root, base: "/_app/", logLevel: "warn", plugins: [tcmPlugin(cb, () => state.plan)], define: { "process.env.NODE_ENV": JSON.stringify("production") }, ssr: { noExternal: true }, build: { ssr: path.join(root, "ssr.ts"), outDir: path.join(staged, SSR_DIR), emptyOutDir: true, rollupOptions: { output: { entryFileNames: "ssr.js" } }, }, }); } const manifest: BuildManifest = { app: appName, hash: state.key, endpoints: [...state.plan.endpoints], assets, ...(state.plan.components.size ? { components: [...state.plan.components], clientRefs: [...sentByReference(state.plan)], serverRefs: [...state.plan.serverRefs], ...(lazy.chunks.size ? { chunks: Object.fromEntries(lazy.chunks) } : {}), ...(state.plan.packageImports.size ? { packageImports: Object.fromEntries( [...state.plan.packageImports].map(([c, keys]) => [c, [...keys]]), ), } : {}), cacheable: [...cacheableComponents(cb, state.plan.components)], } : {}), ...(state.react ? { ssr: `${SSR_DIR}/ssr.js` } : {}), // (its pages' shells render the same each time, so they're kept, unless its browser code reads the clock or // randomness, which a kept first paint would freeze) ...(state.react && ![...state.plan.browser].some((h) => { const rec = cb.store.getDef(h); return !!rec && ownEffects(rec.ir).some((e) => e === "clock" || e === "random"); }) ? { shells: true } : {}), ...(pwa ? { pwa: { offlineFirst: pwa.offlineFirst } } : {}), ...(tcm ? { tcm } : {}), builtAt: new Date().toISOString(), }; fs.writeFileSync(path.join(staged, "tcm-app.json"), JSON.stringify(manifest, null, 2) + "\n"); // (another build of it may have been put there first: that one, the same build, stays) const placed = publishBuild( appsDir(cb), state.key, staged, path.join(root, "replaced"), (there) => !!opts.force || (!!tcm && there.tcm !== tcm), ); if (!opts.quiet) { console.log( `${green("built")} ${bold(appName)} ${dim(shortHash(state.key))} into ${path.relative(cb.lay.root, outDir)}`, ); reportPlan(cb, appName, state.plan); if (pwa) { console.log( ` pwa installable; its service worker keeps ${pwa.files} file(s) and opens it ${pwa.offlineFirst ? "from them first, online or not" : dim("from the network first (it calls the server), from them offline")}`, ); for (const w of pwa.warnings) console.log(yellow(` warning ${w}`)); } } return placed; } }
uses
- tcmts.cli.budget.withSlot
- tcmts.cli.codebase.Codebase
- tcmts.cli.flight.sentByReference
- tcmts.cli.style.bold
- tcmts.cli.style.dim
- tcmts.cli.style.green
- tcmts.cli.style.yellow
- tcmts.cli.web.BuildLease
- tcmts.cli.web.BuildManifest
- tcmts.cli.web.CHUNK_GROUPS
- tcmts.cli.web.SSR_DIR
- tcmts.cli.web.appsDir
- tcmts.cli.web.buildWorkDir
- tcmts.cli.web.cacheableComponents
- tcmts.cli.web.chunkGroup
- tcmts.cli.web.clearLeftovers
- tcmts.cli.web.keepAssets
- tcmts.cli.web.lazyChunks
- tcmts.cli.web.leaseBuilds
- tcmts.cli.web.manifestFile
- tcmts.cli.web.ownId
- tcmts.cli.web.planApp
- tcmts.cli.web.publishBuild
- tcmts.cli.web.pwaAssets
- tcmts.cli.web.reportPlan
- tcmts.cli.web.serviceWorkerSource
- tcmts.cli.web.tcmPlugin
- tcmts.cli.web.withImportMap
- tcmts.cli.web.withPwa
- tcmts.cli.web.withStylesheetFiles
- tcmts.cli.web.writeBuildRoot
- tcmts.core.effects.ownEffects
- tcmts.core.hash.shortHash