branch:
app.ts
14107 bytesRaw
/**
 * App bundler: builds a full-stack app (server Worker + client bundle + static assets)
 * for the Worker Loader binding.
 */

import { bundleWithEsbuild } from "./bundler";
import { hasNodejsCompat, parseWranglerConfig } from "./config";
import { hasDependencies, installDependencies } from "./installer";
import { transformAndResolve } from "./transformer";
import type { AssetConfig, AssetManifest } from "./asset-handler";
import { buildAssetManifest } from "./asset-handler";
import type { CreateWorkerResult, Files, Modules } from "./types";
import { detectEntryPoint } from "./utils";
import { ASSET_RUNTIME_CODE } from "./_asset-runtime-code";
import { showExperimentalWarning } from "./experimental";

/**
 * Options for createApp
 */
export interface CreateAppOptions {
  /**
   * Input files — keys are paths relative to project root, values are file contents.
   * Should include both server and client source files.
   */
  files: Files;

  /**
   * Server entry point (the Worker fetch handler).
   * If not specified, detected from wrangler config / package.json / defaults.
   */
  server?: string;

  /**
   * Client entry point(s) to bundle for the browser.
   * These are bundled with esbuild targeting the browser.
   */
  client?: string | string[];

  /**
   * Static assets to serve as-is (pathname -> content).
   * Keys should be URL pathnames (e.g., "/favicon.ico", "/robots.txt").
   * These are NOT processed by the bundler.
   */
  assets?: Record<string, string | ArrayBuffer>;

  /**
   * Asset serving configuration.
   */
  assetConfig?: AssetConfig;

  /**
   * Whether to bundle server dependencies.
   * @default true
   */
  bundle?: boolean;

  /**
   * External modules that should not be bundled.
   */
  externals?: string[];

  /**
   * Target environment for server bundle.
   * @default 'es2022'
   */
  target?: string;

  /**
   * Whether to minify the output.
   * @default false
   */
  minify?: boolean;

  /**
   * Generate source maps.
   * @default false
   */
  sourcemap?: boolean;

  /**
   * npm registry URL for fetching packages.
   */
  registry?: string;

  /**
   * Generate a Durable Object class wrapper instead of a module worker.
   * When set, the output exports a named class that can be used with
   * ctx.facets.get() / getDurableObjectClass() for persistent storage.
   *
   * If the user's server exports a DurableObject subclass (default export),
   * the wrapper extends it. Otherwise, it wraps the fetch handler in a DO.
   *
   * Pass `true` for className "App", or an object with a custom className.
   */
  durableObject?: { className?: string } | boolean;
}

/**
 * Result from createApp
 */
export interface CreateAppResult extends CreateWorkerResult {
  /**
   * The asset manifest for runtime request handling.
   * Contains metadata (content types, ETags) for each asset.
   */
  assetManifest: AssetManifest;

  /**
   * The asset config for runtime request handling.
   */
  assetConfig?: AssetConfig;

  /**
   * Client bundle output paths (relative to asset root).
   */
  clientBundles?: string[];

  /**
   * The Durable Object class name exported by the wrapper.
   * Only set when `durableObject` option was used.
   * Use with `worker.getDurableObjectClass(className)` and `ctx.facets.get()`.
   */
  durableObjectClassName?: string;
}

/**
 * Creates a full-stack app bundle from source files.
 *
 * This function:
 * 1. Bundles client entry point(s) for the browser (if provided)
 * 2. Collects static assets
 * 3. Bundles the server Worker
 * 4. Generates a server wrapper that serves assets and falls through to user code
 * 5. Returns everything ready for the Worker Loader
 */
export async function createApp(
  options: CreateAppOptions
): Promise<CreateAppResult> {
  showExperimentalWarning("createApp");
  let {
    files,
    bundle = true,
    externals = [],
    target = "es2022",
    minify = false,
    sourcemap = false,
    registry
  } = options;

  // Always treat cloudflare:* as external
  externals = ["cloudflare:", ...externals];

  // Parse wrangler config
  const wranglerConfig = parseWranglerConfig(files);
  const nodejsCompat = hasNodejsCompat(wranglerConfig);

  // Install npm dependencies if needed
  const installWarnings: string[] = [];
  if (hasDependencies(files)) {
    const installResult = await installDependencies(
      files,
      registry ? { registry } : {}
    );
    files = installResult.files;
    installWarnings.push(...installResult.warnings);
  }

  // ── Step 1: Build client bundles ──────────────────────────────────
  const clientEntries = options.client
    ? Array.isArray(options.client)
      ? options.client
      : [options.client]
    : [];

  const clientOutputs: Record<string, string> = {};
  const clientBundles: string[] = [];

  for (const clientEntry of clientEntries) {
    if (!(clientEntry in files)) {
      throw new Error(
        `Client entry point "${clientEntry}" not found in files.`
      );
    }

    // Bundle the client with esbuild targeting browser
    const clientResult = await bundleWithEsbuild(
      files,
      clientEntry,
      externals,
      "es2022", // Browser target
      minify,
      sourcemap,
      false // No nodejs_compat for client
    );

    // Extract the bundled output
    const bundleModule = clientResult.modules["bundle.js"];
    if (typeof bundleModule === "string") {
      // Derive output name from entry
      const baseName = clientEntry
        .replace(/^src\//, "")
        .replace(/\.(tsx?|jsx?)$/, ".js");
      const outputPath = `/${baseName}`;
      clientOutputs[outputPath] = bundleModule;
      clientBundles.push(outputPath);
    }
  }

  // ── Step 2: Collect all assets ────────────────────────────────────
  const allAssets: Record<string, string | ArrayBuffer> = {};

  // Add user-provided static assets
  if (options.assets) {
    for (const [pathname, content] of Object.entries(options.assets)) {
      const normalizedPath = pathname.startsWith("/")
        ? pathname
        : `/${pathname}`;
      allAssets[normalizedPath] = content;
    }
  }

  // Add client bundle outputs
  for (const [pathname, content] of Object.entries(clientOutputs)) {
    allAssets[pathname] = content;
  }

  // Build the asset manifest (metadata only — no content stored)
  const assetManifest = await buildAssetManifest(allAssets);

  // ── Step 3: Build server Worker ───────────────────────────────────
  const serverEntry = options.server ?? detectEntryPoint(files, wranglerConfig);

  if (!serverEntry) {
    throw new Error(
      "Could not determine server entry point. Specify the 'server' option."
    );
  }

  if (!(serverEntry in files)) {
    throw new Error(`Server entry point "${serverEntry}" not found in files.`);
  }

  // Build the server
  let serverResult: CreateWorkerResult;
  if (bundle) {
    serverResult = await bundleWithEsbuild(
      files,
      serverEntry,
      externals,
      target,
      minify,
      sourcemap,
      nodejsCompat
    );
  } else {
    serverResult = await transformAndResolve(files, serverEntry, externals);
  }

  // ── Step 4: Build combined modules ────────────────────────────────
  const modules: Modules = { ...serverResult.modules };

  // Add assets as text or binary modules under __assets/ prefix
  for (const [pathname, content] of Object.entries(allAssets)) {
    const moduleName = `__assets${pathname}`;
    if (typeof content === "string") {
      modules[moduleName] = { text: content };
    } else {
      modules[moduleName] = { data: content };
    }
  }

  // Add the asset manifest as a JSON module
  const manifestJson: Record<
    string,
    { contentType: string | undefined; etag: string }
  > = {};
  for (const [pathname, meta] of assetManifest) {
    manifestJson[pathname] = {
      contentType: meta.contentType,
      etag: meta.etag
    };
  }
  modules["__asset-manifest.json"] = { json: manifestJson };

  // ── Step 5: Generate the app wrapper ──────────────────────────────
  const assetPathnames = [...assetManifest.keys()];

  // Resolve DO class name if durableObject option is set
  const doOption = options.durableObject;
  const doClassName = doOption
    ? typeof doOption === "object" && doOption.className
      ? doOption.className
      : "App"
    : undefined;

  const wrapperCode = doClassName
    ? generateDOAppWrapper(
        serverResult.mainModule,
        assetPathnames,
        doClassName,
        options.assetConfig
      )
    : generateAppWrapper(
        serverResult.mainModule,
        assetPathnames,
        options.assetConfig
      );
  modules["__app-wrapper.js"] = wrapperCode;

  // Include the pre-built asset handler runtime module
  modules["__asset-runtime.js"] = ASSET_RUNTIME_CODE;

  const result: CreateAppResult = {
    mainModule: "__app-wrapper.js",
    modules,
    assetManifest,
    assetConfig: options.assetConfig,
    clientBundles: clientBundles.length > 0 ? clientBundles : undefined,
    durableObjectClassName: doClassName
  };

  if (wranglerConfig !== undefined) {
    result.wranglerConfig = wranglerConfig;
  }

  if (installWarnings.length > 0) {
    result.warnings = [...(serverResult.warnings ?? []), ...installWarnings];
  } else if (serverResult.warnings) {
    result.warnings = serverResult.warnings;
  }

  return result;
}

/**
 * Generate the asset imports + initialization preamble shared by both wrappers.
 * Returns the import statements and the initialization code that creates
 * the manifest Map, memory storage, and ASSET_CONFIG for handleAssetRequest.
 */
function generateAssetPreamble(
  assetPathnames: string[],
  assetConfig?: AssetConfig
): { importsBlock: string; initBlock: string } {
  const configJson = JSON.stringify(assetConfig ?? {});

  const imports: string[] = [];
  const mapEntries: string[] = [];
  for (let i = 0; i < assetPathnames.length; i++) {
    const pathname = assetPathnames[i];
    const moduleName = `__assets${pathname}`;
    const varName = `__asset_${i}`;
    imports.push(`import ${varName} from "./${moduleName}";`);
    mapEntries.push(`  ${JSON.stringify(pathname)}: ${varName}`);
  }

  const importsBlock = [
    'import { handleAssetRequest, createMemoryStorage } from "./__asset-runtime.js";',
    'import manifestJson from "./__asset-manifest.json";',
    ...imports
  ].join("\n");

  const contentMapBlock = `const ASSET_CONTENT = {\n${mapEntries.join(",\n")}\n};`;

  const initBlock = `
const ASSET_CONFIG = ${configJson};
${contentMapBlock}

// Build manifest Map and storage at module init time
const manifest = new Map(Object.entries(manifestJson));
const storage = createMemoryStorage(ASSET_CONTENT);
`.trimStart();

  return { importsBlock, initBlock };
}

/**
 * Generate the app wrapper module source.
 * This Worker serves assets first, then falls through to the user's server.
 *
 * Uses the pre-built __asset-runtime.js module for full asset handling
 * (all HTML modes, redirects, custom headers, ETag caching, etc.)
 */
function generateAppWrapper(
  userServerModule: string,
  assetPathnames: string[],
  assetConfig?: AssetConfig
): string {
  const { importsBlock, initBlock } = generateAssetPreamble(
    assetPathnames,
    assetConfig
  );

  return `
import userWorker from "./${userServerModule}";
${importsBlock}

${initBlock}
export default {
  async fetch(request, env, ctx) {
    const assetResponse = await handleAssetRequest(request, manifest, storage, ASSET_CONFIG);
    if (assetResponse) return assetResponse;

    // Fall through to user's Worker
    if (typeof userWorker === "object" && userWorker !== null && typeof userWorker.fetch === "function") {
      return userWorker.fetch(request, env, ctx);
    }
    if (typeof userWorker === "function") {
      return userWorker(request, env, ctx);
    }

    return new Response("Not Found", { status: 404 });
  }
};
`.trim();
}

/**
 * Generate a Durable Object class wrapper module source.
 * Exports a named class that serves assets first, then delegates to the
 * user's server code. If the user's default export is a class (DurableObject
 * subclass), the wrapper extends it so `this.ctx.storage` works naturally.
 * Otherwise, it wraps the fetch handler in a DurableObject.
 *
 * Uses the pre-built __asset-runtime.js module for full asset handling.
 */
function generateDOAppWrapper(
  userServerModule: string,
  assetPathnames: string[],
  className: string,
  assetConfig?: AssetConfig
): string {
  const { importsBlock, initBlock } = generateAssetPreamble(
    assetPathnames,
    assetConfig
  );

  return `
import { DurableObject } from "cloudflare:workers";
import userExport from "./${userServerModule}";
${importsBlock}

${initBlock}
// Determine base class: if user exported a DurableObject subclass, extend it
// so this.ctx.storage works naturally. Regular functions and plain objects are
// wrapped in a minimal DurableObject that delegates fetch().
// NOTE: This check uses prototype presence — regular (non-arrow) functions also
// have .prototype, but the system prompt instructs class exports for DO mode.
const BaseClass = (typeof userExport === "function" && userExport.prototype)
  ? userExport
  : class extends DurableObject {
      async fetch(request) {
        if (typeof userExport === "object" && userExport !== null && typeof userExport.fetch === "function") {
          return userExport.fetch(request, this.env, this.ctx);
        }
        return new Response("Not Found", { status: 404 });
      }
    };

export class ${className} extends BaseClass {
  async fetch(request) {
    const assetResponse = await handleAssetRequest(request, manifest, storage, ASSET_CONFIG);
    if (assetResponse) return assetResponse;
    return super.fetch(request);
  }
}
`.trim();
}