Read OSS

From Source to Edge: The Bundling System and Deploy Pipeline

Intermediate

Prerequisites

  • Articles 1 and 2 in this series
  • Basic esbuild knowledge (plugins, conditions, targets)
  • Familiarity with the Cloudflare Workers upload API

From Source to Edge: The Bundling System and Deploy Pipeline

When you run wrangler deploy, your TypeScript Worker doesn't travel directly to Cloudflare's network. It passes through a multi-stage pipeline: esbuild transforms and bundles the source, custom plugins resolve Workers-specific modules, a module collector categorizes the output by MIME type, and the result is packaged into a multipart FormData upload alongside a metadata blob that carries your bindings, compatibility settings, and deployment configuration.

This article traces every step of that pipeline, from the esbuild configuration choices that make Workers bundling different from browser bundling, through the five custom esbuild plugins, to the full deploy() function that handles remote config diffing before uploading.

bundleWorker() vs noBundleWorker()

Wrangler supports two bundling modes. The primary path uses bundleWorker() at packages/wrangler/src/deployment-bundle/bundle.ts#L149, which invokes esbuild with the full plugin pipeline. The alternative, noBundleWorker() at packages/wrangler/src/deployment-bundle/no-bundle-worker.ts#L9-L32, skips esbuild entirely and just scans for additional modules.

noBundleWorker() is invoked when the user passes --no-bundle — typically because their Worker is already bundled by another tool or is a single-file Worker that doesn't need transformation. It still runs findAdditionalModules() to discover Wasm files, text assets, and other module types that need to be included in the upload.

The shared esbuild configuration is defined as COMMON_ESBUILD_OPTIONS at packages/wrangler/src/deployment-bundle/bundle.ts#L46-L52:

export const COMMON_ESBUILD_OPTIONS = {
    target: "es2024",
    supported: { "import-source": true },
    loader: { ".js": "jsx", ".mjs": "jsx", ".cjs": "jsx" },
} as const;

The target: "es2024" is notable — workerd's V8 supports ES2024 features, so there's no need to downlevel modern syntax. The .jsjsx loader mapping enables JSX in plain .js files without requiring a .jsx extension, matching the convention in the Workers ecosystem.

flowchart TD
    SRC["Worker source (TS/JS)"] --> DECISION{--no-bundle?}
    DECISION -->|No| BUNDLE["bundleWorker()<br/>Full esbuild pipeline"]
    DECISION -->|Yes| NOBUNDLE["noBundleWorker()<br/>Module scan only"]
    BUNDLE --> RESULT["BundleResult<br/>modules + dependencies + sourcemap"]
    NOBUNDLE --> RESULT

Workers Build Conditions: workerd, worker, browser

The getBuildConditions() function at packages/wrangler/src/deployment-bundle/bundle.ts#L69-L76 sets the esbuild conditions that determine how package.json exports fields are resolved:

export function getBuildConditions() {
    const envVar = getBuildConditionsFromEnv();
    if (envVar !== undefined) {
        return envVar.split(",");
    } else {
        return ["workerd", "worker", "browser"];
    }
}

The ordering matters. When a library's package.json uses conditional exports:

{
  "exports": {
    ".": {
      "workerd": "./dist/workerd.js",
      "worker": "./dist/worker.js",
      "browser": "./dist/browser.js",
      "default": "./dist/node.js"
    }
  }
}

esbuild will prefer workerd (the most specific Cloudflare runtime target), then worker (generic web worker), then browser, and finally default. This ensures libraries that have optimized code paths for the workerd runtime get used preferentially.

Condition Purpose
workerd Cloudflare's Workers runtime — most specific
worker Generic Web Worker standard
browser Browser environment (fallback for isomorphic code)
default Always active (esbuild built-in)
import Active for ESM syntax (esbuild built-in)

Tip: You can override conditions via the WRANGLER_BUILD_CONDITIONS environment variable. This is useful when testing how your Worker behaves with different library export paths.

Custom esbuild Plugins

The bundleWorker() function registers several custom esbuild plugins. Three deserve particular attention:

Node.js Compatibility Plugins

The packages/wrangler/src/deployment-bundle/esbuild-plugins/nodejs-plugins.ts module provides Node.js API compatibility shims via the unenv package. When nodejs_compat or nodejs_compat_v2 compatibility flags are enabled, these plugins intercept imports of node:* built-in modules and redirect them to polyfill implementations that work in the Workers runtime.

Cloudflare Internal Module Resolution

The packages/wrangler/src/deployment-bundle/esbuild-plugins/cloudflare-internal.ts plugin handles resolution of cloudflare:* virtual modules (like cloudflare:email, cloudflare:sockets) and workerd:* imports. These aren't real files on disk — they're runtime-provided modules that need to be marked as external so esbuild doesn't try to bundle them.

Config Provider Plugin

The packages/wrangler/src/deployment-bundle/esbuild-plugins/config-provider.ts injects wrangler configuration values at build time, enabling compile-time access to config without runtime overhead.

flowchart LR
    ESBUILD["esbuild"] --> NODEJS["nodejs-plugins<br/>Node.js polyfills via unenv"]
    ESBUILD --> CF["cloudflare-internal<br/>cloudflare:* & workerd:* modules"]
    ESBUILD --> CP["config-provider<br/>Build-time config injection"]
    ESBUILD --> BR["buildResultPlugin<br/>Captures initial build result"]
    ESBUILD --> LOG["log-build-output<br/>Bundle size reporting"]

The bundleWorker() function also injects middleware loaders for development mode. In dev, Workers get wrapped with middleware for request body draining, scheduled event testing, and JSON error formatting (for pretty error pages in Miniflare).

Module Collection and MIME Type Mapping

After esbuild produces its output, the module collector categorizes each output file by type. The MIME type mapping at packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts#L22-L32 defines the translation:

Module Type MIME Type
esm application/javascript+module
commonjs application/javascript
compiled-wasm application/wasm
buffer application/octet-stream
text text/plain
python text/x-python
python-requirement text/x-python-requirement

This mapping is critical because Cloudflare's Workers API uses the Content-Type header on each part of the multipart upload to determine how to handle the module. A WASM file sent with the wrong MIME type would fail to load at runtime.

The createModuleCollector() function works as an esbuild plugin — it intercepts esbuild's module resolution to track which files are included in the bundle and categorize them before the upload form is constructed.

createWorkerUploadForm(): Building the Multipart Upload

The createWorkerUploadForm() function constructs the FormData object sent to Cloudflare's API. The upload format is a multipart form with:

  1. metadata — A JSON blob containing the worker's bindings, compatibility date/flags, usage model, placement, tail consumers, observability settings, and asset configuration
  2. Main module — The entry point script as the first named part
  3. Additional modules — Each additional module (WASM files, text assets, sub-modules) as separate named parts with appropriate MIME types
  4. Source maps — Optional source map files for error stack trace mapping

The function extracts bindings by type — plain_text, json, secret_text, kv_namespace, durable_object_namespace, queue, r2_bucket, d1, and many more — then converts them into the metadata format expected by the API at packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts#L115-L134.

flowchart TD
    WORKER["Worker init data"] --> FORM["createWorkerUploadForm()"]
    BINDINGS["Bindings"] --> FORM
    FORM --> META["metadata (JSON):<br/>bindings, compat date,<br/>placement, limits"]
    FORM --> MAIN["main module:<br/>application/javascript+module"]
    FORM --> MODS["additional modules:<br/>WASM, text, etc."]
    FORM --> SMAP["source maps"]
    META --> FD["FormData"]
    MAIN --> FD
    MODS --> FD
    SMAP --> FD

The deploy() Function: End-to-End Flow

The full deploy flow in packages/wrangler/src/deploy/deploy.ts orchestrates everything:

flowchart TD
    START["deploy(props)"] --> CHECK["Check if worker exists"]
    CHECK --> DIFF["Fetch remote config<br/>Compare with local"]
    DIFF --> WARN{Destructive changes?}
    WARN -->|Yes| CONFIRM["Prompt user to confirm"]
    WARN -->|No| BUNDLE
    CONFIRM --> BUNDLE["Bundle worker<br/>(bundleWorker or noBundleWorker)"]
    BUNDLE --> FORM["createWorkerUploadForm()"]
    FORM --> UPLOAD["Upload to Workers API"]
    UPLOAD --> ROUTES["Publish routes/custom domains"]
    ROUTES --> QUEUES["Reconcile queue consumers"]
    QUEUES --> VERSION["Create version/deployment"]
    VERSION --> DONE["Log success + URL"]

The config diffing step at packages/wrangler/src/deploy/deploy.ts#L448-L460 is a safety net. If the Worker was last deployed from the Cloudflare dashboard (rather than Wrangler), the function fetches the remote configuration and compares it with the local config. If the local deployment would overwrite manual dashboard edits (destructive changes), the user is warned and prompted to confirm.

The command definition itself at packages/wrangler/src/deploy/index.ts#L36-L42 uses the createCommand() pattern from Article 2:

export const deployCommand = createCommand({
    metadata: {
        description: "🆙 Deploy a Worker to Cloudflare",
        owner: "Workers: Deploy and Config",
        status: "stable",
        category: "Compute & AI",
    },
    // ...
});

Tip: The --dry-run flag on wrangler deploy exercises the entire bundling pipeline and constructs the upload form without actually sending it. This is invaluable for CI pipelines where you want to validate that a Worker bundles correctly without deploying.

What's Next

We've now traced the path from source code to Cloudflare's edge. But there's a parallel path to local development that doesn't go through Wrangler at all — the @cloudflare/vite-plugin provides a first-class Vite integration that uses the same Miniflare runtime core but through a completely different orchestration layer. Article 6 examines the Vite plugin and the shared configuration system that ensures both tools interpret your wrangler.toml identically.