Ctrl K
ComunidadGuías…

scripts/edge-bundle-lib.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Shared edge-bundle pipeline for the two production builds.
 *
 * `build-edge.ts` (Bunny Edge Scripting) and `build-deploy.ts` (Deno Deploy)
 * produce the same shape of single-file ESM bundle: the same static-asset and
 * codec-wasm inlining, build-info/asset-paths modules, Node-global banner, and
 * `platform: "browser"` config (which resolves
 * `@libsql/client` to its pure-JS `web` export). Everything they share lives
 * here; each caller passes only what genuinely differs — the entry point, any
 * extra guards (the deploy build asserts no native libsql binding leaked in),
 * an optional content transform, and how the built bundle is emitted.
 *
 * The pure string-building and guard logic lives in `edge-bundle-modules.ts`
 * (unit- and mutation-tested); this file is the IO shell that reads assets,
 * drives esbuild, runs the guards, and hands the bundle to `emit`.
 */

import { denoPlugins } from "@luca/esbuild-deno-loader";
import { dirname, fromFileUrl } from "@std/path";
import type { Plugin } from "esbuild";
import * as esbuild from "esbuild";
import { ASSETS } from "#shared/images/wasm-assets.ts";
import { runStaticAssetBuild } from "./build-static-assets.ts";
import { minifyCss } from "./css-minify.ts";
import {
  buildAssetPathsModule,
  buildAssetsModule,
  buildBuildInfoModule,
  type EdgeBundleGuard,
  reactRuntimeGuard,
  SENTRY_BUILD_DEFINES,
  sentryTreeShakingGuard,
} from "./edge-bundle-modules.ts";
import { ASSET_DEFS, buildCdnAssets } from "./edge-cdn-assets.ts";
import {
  buildRemoteModule,
  createPlugin as createWasmPlugin,
  inlineWasmPlugin,
} from "./inline-jsquash-wasm.ts";
import { publishStaticCdnAssets, type StaticCdnConfig } from "./static-cdn.ts";

export type { EdgeBundleGuard } from "./edge-bundle-modules.ts";

export interface EdgeBundleContext {
  /** ISO build timestamp baked into the bundle; reused for the release tag. */
  buildIso: string;
  /** The final bundle text (after `transformContent`). */
  content: string;
}

export interface EdgeBundleOptions {
  /** Publish fixed browser/WASM payloads and bake their immutable CDN URLs. */
  cdnConfig?: StaticCdnConfig | null;
  /** Emit the finished bundle (write files, copy the map, …) and return. */
  emit: (ctx: EdgeBundleContext) => Promise<void>;
  /** Inline empty strings instead of the real asset bodies — for benchmark
   * builds that measure what the inlined payloads themselves cost. */
  emptyInlinedAssets?: boolean;
  /** Entry point to bundle, e.g. `"./src/edge.ts"`. */
  entryPoint: string;
  /** Extra assertions beyond the shared React-runtime guard. */
  guards?: EdgeBundleGuard[];
  /** Human label used in guard messages and the completion log ("Edge"/"Deploy"). */
  label: string;
  /** Basename esbuild writes under `./dist`, e.g. `"edge.js"`. */
  outfile: string;
  /** Reuse already-built client bundles instead of rebuilding them (for the
   * benchmarks that bundle several entry points back to back). */
  skipClientBuild?: boolean;
  /** Transform the raw `dist/<outfile>` text before guards run (e.g. rename the source-map link). */
  transformContent?: (raw: string) => string;
}

// Externalize all Node.js built-in modules (per Bunny docs; Deno Deploy
// provides them natively too).
import { builtinModules } from "node:module";

const nodeExternals = [
  ...builtinModules,
  ...builtinModules.map((m) => `node:${m}`),
];

// Banner to inject Node.js globals that many packages expect (per Bunny docs).
// process.env is populated by the runtime's native secrets at runtime.
const NODEJS_GLOBALS_BANNER = `import * as process from "node:process";
import { Buffer } from "node:buffer";
globalThis.process ??= process;
globalThis.Buffer ??= Buffer;
globalThis.global ??= globalThis;
`;

// Swaps a real module for a generated one at build time: esbuild resolves any
// import matching `filter` into its own private namespace, then loads that
// namespace from the TypeScript source `buildContents` returns.
const inlineGeneratedModule = (
  build: Parameters<Plugin["setup"]>[0],
  filter: RegExp,
  namespace: string,
  buildContents: () => string,
): void => {
  build.onResolve({ filter }, (args) => ({ namespace, path: args.path }));
  build.onLoad({ filter: /.*/, namespace }, () => ({
    contents: buildContents(),
    loader: "ts",
  }));
};

/**
 * Plugin to inline static assets and handle Deno-specific imports.
 * Replaces Deno.readTextFileSync calls with the pre-read content and the
 * build metadata modules produced by the pure builders.
 */
const inlineAssetsPlugin = (
  buildIso: string,
  buildTs: number,
  staticAssets: Record<string, string>,
  published?: Awaited<ReturnType<typeof publishStaticCdnAssets>>,
): Plugin => ({
  name: "inline-assets",
  setup(build) {
    // Replace build-info module with actual build metadata
    inlineGeneratedModule(build, /build-info\.ts$/, "inline-build-info", () =>
      buildBuildInfoModule(buildIso, Deno.env.get("BUILD_COMMIT") ?? ""),
    );

    // Replace asset paths module with cache-busted version
    inlineGeneratedModule(build, /asset-paths\.ts$/, "inline-asset-paths", () =>
      buildAssetPathsModule(ASSET_DEFS, buildTs, published),
    );

    // Replace the assets module with inlined content
    inlineGeneratedModule(
      build,
      /(features\/assets\.ts$|#routes\/assets\.ts$)/,
      "inline-assets",
      () => buildAssetsModule(ASSET_DEFS, staticAssets, published),
    );
  },
});

/** Read every static asset ASSET_DEFS references, plus the inlined order widget. */
export const readStaticAssets = async (
  minifiedCss: string,
): Promise<Record<string, string>> => {
  const staticAssets: Record<string, string> = {
    "favicon.svg": await Deno.readTextFile("./src/ui/static/favicon.svg"),
    "style.css": minifiedCss,
  };

  for (const [filename] of ASSET_DEFS) {
    if (filename === "favicon.svg" || filename === "style.css") continue;
    staticAssets[filename] = await Deno.readTextFile(
      `./src/ui/static/${filename}`,
    );
  }

  // The external-order widget is served by a dynamic route (not an ASSET_DEFS
  // handler), but its body must still be inlined for the bundled runtime, which
  // has no filesystem. Read it here so buildAssetsModule() can bake it in.
  staticAssets["order.js"] = await Deno.readTextFile(
    "./src/ui/static/order.js",
  );

  return staticAssets;
};

/**
 * Run the shared bundle pipeline: build client bundles, inline static assets,
 * bundle the given entry point with esbuild, run the shared and caller guards,
 * then hand the finished bundle to `emit`.
 */
export const buildEdgeBundle = async (
  options: EdgeBundleOptions,
): Promise<void> => {
  const { label, entryPoint, outfile, transformContent, guards } = options;

  // --- Step 1: Build client bundles ---
  if (!options.skipClientBuild) {
    const staticAssets = await runStaticAssetBuild(false);
    await staticAssets.dispose();
  }

  // --- Step 2: Build the edge bundle ---

  // Build timestamp for cache-busting (seconds since epoch)
  const buildTs = Math.floor(Date.now() / 1000);

  // Read static assets at build time for inlining (client bundles freshly built above)
  const rawCss = await Deno.readTextFile("./src/ui/static/style.css");
  const minifiedCss = await minifyCss(rawCss);
  const staticAssets = await readStaticAssets(minifiedCss);
  const inlinedAssets = options.emptyInlinedAssets
    ? Object.fromEntries(Object.keys(staticAssets).map((key) => [key, ""]))
    : staticAssets;
  const published = options.cdnConfig
    ? await publishStaticCdnAssets(
        options.cdnConfig,
        buildCdnAssets(staticAssets),
      )
    : undefined;
  const wasmPlugin = published
    ? createWasmPlugin(() => buildRemoteModule(ASSETS, published.urls))
    : inlineWasmPlugin;

  // Build timestamp — always the current time. Used both as BUILD_TIMESTAMP
  // and (formatted) as the release tag in release builds, so the two always match.
  const buildIso = new Date().toISOString();

  await esbuild.build({
    banner: { js: NODEJS_GLOBALS_BANNER },
    bundle: true,
    define: {
      "process.env.NODE_ENV": '"production"',
      ...SENTRY_BUILD_DEFINES,
    },
    entryPoints: [entryPoint],
    external: nodeExternals,
    format: "esm",
    jsx: "automatic",
    jsxImportSource: "#jsx",
    minify: true,
    outdir: "./dist",
    platform: "browser",
    plugins: [
      inlineAssetsPlugin(buildIso, buildTs, inlinedAssets, published),
      wasmPlugin,
      // The loader pins older structural esbuild types; its runtime plugins
      // implement the same API used by our newer esbuild package.
      ...(denoPlugins({
        configPath: fromFileUrl(new URL("../deno.json", import.meta.url)),
      }) as unknown as Plugin[]),
    ],
    // Emit a linked source map so deploys can upload it to Sentry for readable
    // (un-minified) stack traces. Harmless when no upload runs — the deployed
    // bundle just carries a `sourceMappingURL` comment.
    sourcemap: true,
  });

  // esbuild.build() throws on failure, so if we reach here the output file exists.
  const raw = await Deno.readTextFile(`./dist/${outfile}`);
  const content = transformContent ? transformContent(raw) : raw;

  for (const guard of [
    reactRuntimeGuard(label),
    sentryTreeShakingGuard,
    ...(guards ?? []),
  ]) {
    if (guard.test(content)) {
      console.error(guard.message);
      Deno.exit(1);
    }
  }

  await options.emit({ buildIso, content });

  // Clean up esbuild
  esbuild.stop();
};

/** A `buildEdgeBundle` emit callback that writes the bundle straight to
 *  `path`. */
export const emitTo =
  (path: string) =>
  async ({ content }: { content: string }): Promise<void> => {
    await Deno.writeTextFile(path, content);
  };

/** Build the production pipeline bundle of the shared app core
 *  (`src/serve-app.ts`, no platform wrapper registers a server) and write it
 *  to `path`. The bundle-load benchmark and the production-suite smoke both
 *  boot exactly this artifact. */
export const buildServeAppBundle = async (
  path: string,
  label: string,
): Promise<void> => {
  await Deno.mkdir(dirname(path), { recursive: true });
  await buildEdgeBundle({
    emit: emitTo(path),
    entryPoint: "./src/serve-app.ts",
    label,
    // esbuild names the output after the entry basename under ./dist.
    outfile: "serve-app.js",
  });
};