/** * 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; /** * 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 { 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 = {}; 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 = {}; // 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(); }