Ctrl K
ComunidadGuías…

scripts/build-static-assets.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Build static client assets (admin, scanner, iframe-resizer, embed loader).
 */

import { denoPlugins } from "@luca/esbuild-deno-loader";
import { fromFileUrl, resolve } from "@std/path";
import * as esbuild from "esbuild";
import * as sass from "sass";
import { projectRoot } from "./project-root.ts";
import { withStaticAssetBuildLock } from "./static-assets/build-lock.ts";
import { writeStaticAssetManifest } from "./static-assets/cache.ts";
import { fileInputs } from "./static-assets/inputs.ts";
import {
  CSS_ENTRY,
  STATIC_ASSET_OUTFILES,
  staticAssetOutputFiles,
} from "./static-assets/outfiles.ts";
import { withGeneratedOutputRollback } from "./static-assets/output-rollback.ts";
import {
  createStaticAssetBuild,
  disposeStaticBundleContexts,
  rebuildStaticBundleContexts,
  type StaticAssetBuild,
  type StaticBundle,
  settleAll,
} from "./static-assets/session.ts";

/**
 * deno.json path the deno-loader uses to resolve every bundle's imports —
 * the `#` import map, npm/jsr specifiers, and each package's browser entry —
 * exactly as the edge build (`build-edge.ts`) does. This replaces the
 * per-package hand-rolled resolve plugins this file used to carry.
 */
const configPath = fromFileUrl(new URL("../deno.json", import.meta.url));

/**
 * The loader ships its own esbuild type declarations, whose PluginBuild type can
 * drift from the npm esbuild package even though the runtime plugin shape is the
 * same. Keep that incompatibility at this adapter boundary.
 */
const denoLoaderPlugins = (): esbuild.Plugin[] =>
  denoPlugins({ configPath }) as unknown as esbuild.Plugin[];

/** Compile the SCSS stylesheet to the served CSS file, reporting every
 *  stylesheet it read so the build cache can watch them all. */
const buildCss = async (quiet = false): Promise<string[]> => {
  const { css, loadedUrls } = sass.compile(CSS_ENTRY, { style: "compressed" });
  await Deno.writeTextFile(STATIC_ASSET_OUTFILES.css, css);
  if (!quiet) console.log(`CSS build complete: ${STATIC_ASSET_OUTFILES.css}`);
  return loadedUrls
    .filter((url) => url.protocol === "file:")
    .map((url) => fromFileUrl(url));
};

/** A browser bundle built the standard way (bundled, minified, IIFE, deno
 *  loader plugins). `extra` overrides that default per bundle — the order
 *  widget needs ESM, the iframe-resizer child needs a licence banner. */
const browserBundle = (
  label: string,
  entryPoint: string,
  outfile: string,
  extra: Omit<esbuild.BuildOptions, "outfile"> = {},
): StaticBundle => ({
  label,
  options: {
    bundle: true,
    entryPoints: [entryPoint],
    format: "iife",
    minify: true,
    outfile,
    platform: "browser",
    plugins: denoLoaderPlugins(),
    ...extra,
  },
});

export const STATIC_JS_BUNDLES: StaticBundle[] = [
  // ESM (not IIFE): the scanner is loaded as `<script type="module">`, and it
  // exports the pieces its direct mirror test drives through the built
  // bundle.
  browserBundle(
    "Scanner",
    "./src/ui/client/scanner.js",
    STATIC_ASSET_OUTFILES.scanner,
    { format: "esm" },
  ),
  browserBundle(
    "Scanner service worker",
    "./src/ui/client/scanner-sw.js",
    STATIC_ASSET_OUTFILES.scannerSw,
  ),
  browserBundle(
    "Admin",
    "./src/ui/client/admin.ts",
    STATIC_ASSET_OUTFILES.admin,
  ),
  browserBundle(
    "Logistics map",
    "./src/ui/client/logistics-map.ts",
    STATIC_ASSET_OUTFILES.logisticsMap,
  ),
  browserBundle(
    "Markdown editor",
    "./src/ui/client/markdown-editor.ts",
    STATIC_ASSET_OUTFILES.markdownEditor,
  ),
  browserBundle(
    "Embed",
    "./src/ui/client/embed-main.ts",
    STATIC_ASSET_OUTFILES.embed,
  ),
  browserBundle(
    "Contact",
    "./src/ui/client/contact.ts",
    STATIC_ASSET_OUTFILES.contact,
  ),
  // ESM (not IIFE): the order widget is loaded as `<script type="module">`,
  // and that module form is what makes the cross-origin CORS gate bite — a
  // classic-script include would bypass it. The `export {}` in order.ts also
  // forces module-only parsing.
  browserBundle(
    "Order",
    "./src/ui/client/order.ts",
    STATIC_ASSET_OUTFILES.order,
    {
      format: "esm",
    },
  ),
  browserBundle(
    "iframe-resizer-parent",
    "./src/ui/client/iframe-resizer-parent.ts",
    STATIC_ASSET_OUTFILES.iframeResizerParent,
  ),
  browserBundle(
    "iframe-resizer-child",
    "./src/ui/client/iframe-resizer-child.ts",
    STATIC_ASSET_OUTFILES.iframeResizerChild,
    { banner: { js: "window.iframeResizer={license:'GPLv3'};" } },
  ),
];

const outfileOf = (bundle: StaticBundle): string =>
  resolve(Deno.cwd(), bundle.options.outfile);

/** Files outside the bundled sources that still decide the build's output:
 *  the import map and lockfile every bundle resolves through, and the bundle
 *  list itself. A change to any of them must force a rebuild. */
const BUILD_DEFINITION_FILES = [
  "deno.json",
  "deno.lock",
  "scripts/build-static-assets.ts",
  "scripts/static-assets/outfiles.ts",
];

const inputsOf = (
  result: esbuild.BuildResult | undefined,
  bundle: StaticBundle,
): string[] => {
  if (!result?.metafile) {
    throw new Error(`Static bundle has no build metadata: ${bundle.label}`);
  }
  return fileInputs(Object.keys(result.metafile.inputs));
};

const createBundleContexts = async (): Promise<
  Array<{ bundle: StaticBundle; context: esbuild.BuildContext }>
> => {
  const created: Array<{
    bundle: StaticBundle;
    context: esbuild.BuildContext;
  }> = [];
  try {
    for (const bundle of STATIC_JS_BUNDLES) {
      created.push({
        bundle,
        context: await esbuild.context({
          ...bundle.options,
          logLevel: "silent",
          metafile: true,
        }),
      });
    }
    return created;
  } catch (error) {
    await disposeStaticBundleContexts(created);
    throw error;
  }
};

/** Bundle and compile every browser asset from scratch, waiting for its turn
 *  at the shared build lock. `quiet` has no default on purpose: the callers
 *  decide it, so the entry points cannot drift. */
export const runStaticAssetBuild = (
  quiet: boolean,
): Promise<StaticAssetBuild> =>
  withStaticAssetBuildLock(() => buildEveryStaticAsset(quiet));

/** The build itself, with the lock already held. Only for a caller that took
 *  the lock so it could decide something first — `prepareStaticAssets` asks
 *  again, once it is its turn, whether the assets still need building at all.
 *  Taking the lock twice in one process would wait on itself forever. */
export const buildEveryStaticAsset = async (
  quiet: boolean,
): Promise<StaticAssetBuild> => {
  let contexts: Awaited<ReturnType<typeof createBundleContexts>> = [];
  // Noted before anything is read, so a source saved mid-build is recognised
  // as one we cannot vouch for — see writeStaticAssetManifest.
  const startedAt = Date.now();
  return withGeneratedOutputRollback(
    staticAssetOutputFiles(),
    async () => {
      contexts = await createBundleContexts();
      const cssBuild = buildCss(quiet);
      const bundleBuild = rebuildStaticBundleContexts(contexts);
      await settleAll([cssBuild, bundleBuild]);
      const results = await bundleBuild;
      const bundles = await Promise.all(
        contexts.map(async ({ bundle, context }, index) => {
          if (!quiet) {
            console.log(
              `${bundle.label} build complete: ${bundle.options.outfile}`,
            );
          }
          return {
            baseline: await Deno.readFile(outfileOf(bundle)),
            bundle,
            context,
            inputs: inputsOf(results[index], bundle),
          };
        }),
      );
      await writeStaticAssetManifest({
        inputs: [
          ...bundles.flatMap((built) =>
            built.inputs.map((input) => resolve(Deno.cwd(), input)),
          ),
          ...(await cssBuild),
          ...BUILD_DEFINITION_FILES.map((file) => resolve(projectRoot, file)),
        ],
        outputs: staticAssetOutputFiles(),
        startedAt,
      });
      return createStaticAssetBuild(bundles, {
        resolve: (file) => resolve(Deno.cwd(), file),
        stop: () => esbuild.stop(),
        write: (file, contents) =>
          Deno.writeFile(resolve(Deno.cwd(), file), contents),
      });
    },
    () => [() => disposeStaticBundleContexts(contexts), () => esbuild.stop()],
  );
};

if (import.meta.main) {
  const build = await runStaticAssetBuild(false);
  await build.dispose();
}