How does the bundler/plugin system work?

HEAD
c1d7c8dAdd support for `"use step"` functions in class instance methods (#777)
This post might have stale content, as HEAD is 46 commits ahead.

how does the bundler/plugin system work? all via nitro?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Now I have a comprehensive understanding. Let me summarize the bundler/plugin architecture:


##Bundler/Plugin System Architecture

The bundler/plugin system is not all via Nitro. It's actually a layered architecture with shared building blocks that different framework integrations use in different ways. Here's how it works:

###Core Building Blocks (@workflow/builders)

The central package is @workflow/builders which provides:

  1. BaseBuilder - Abstract base class with common build logic using esbuild for bundling + SWC for code transformation
  2. Transform utilities - Pattern detection (detectWorkflowPatterns) to identify files with "use workflow", "use step" directives, or serde patterns
  3. esbuild plugins - Discovery plugin, SWC transform plugin, pseudo-package plugin
  4. VercelBuildOutputAPIBuilder - For Vercel deployment output

###SWC Plugin (@workflow/swc-plugin-workflow)

A Rust-based SWC plugin that performs the actual code transformation:

  • Runs in different "modes": workflow, step, or client
  • Transforms directive-annotated code appropriately for each context

###Framework-Specific Integrations

Each framework has its own integration layer that uses the shared building blocks:

FrameworkPackageTransform HookNotes
Next.js@workflow/nextWebpack loader + Turbopack rulesUses custom NextBuilder, adds webpack/turbopack loader that runs SWC transform
Nitro@workflow/nitroRollup rollup:before hookUses @workflow/rollup plugin, injects into Nitro's rollup config
Nuxt@workflow/nuxtDelegates to NitroJust configures @workflow/nitro as a Nitro module
SvelteKit@workflow/sveltekitVite pluginUses @workflow/rollup transform + @workflow/vite hot-update
Astro@workflow/astroVite pluginSame pattern: rollup transform + vite hot-update

###Rollup Plugin (@workflow/rollup)

The workflowTransformPlugin() is the key shared transform plugin:

  • Detects files needing transformation via pattern matching
  • Runs SWC with the workflow plugin in client mode
  • Used by: Nitro, SvelteKit, Astro (via Vite's Rollup compatibility)

###Vite Plugin (@workflow/vite)

Provides workflowHotUpdatePlugin() for HMR support:

  • Watches for changes to workflow/step files
  • Triggers rebuild when detected
  • Used by: SvelteKit, Astro

###The Build Flow

Source Files (with "use workflow"/"use step")
            ↓
┌───────────────────────────────────────────┐
│  Framework Integration Layer              │
│  (Next.js loader / Nitro rollup hook /    │
│   Vite plugin)                            │
└───────────────────────────────────────────┘
            ↓
┌───────────────────────────────────────────┐
│  @workflow/rollup or custom loader        │
│  - Pattern detection                      │
│  - SWC transform with @workflow/swc-plugin│
└───────────────────────────────────────────┘
            ↓
┌───────────────────────────────────────────┐
│  Framework Builder (extends BaseBuilder)  │
│  - esbuild for bundling                   │
│  - Creates steps bundle                   │
│  - Creates workflows bundle               │
│  - Creates webhook routes                 │
│  - Generates manifest                     │
└───────────────────────────────────────────┘
            ↓
   Generated route handlers + bundles

###Key Insight

Nitro is just one integration, not the foundation. The system is designed modularly:

  • esbuild handles bundling
  • SWC (via Rust plugin) handles code transformation
  • Each framework wrapper hooks into its build system appropriately
  • The @workflow/builders package provides the shared bundling logic that all integrations use
Avatar of julianbenegas
Julian Benegas
commented

how do those routes get called after? how does workflow know what the origin is?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Now I have a complete picture. Let me summarize how routes get called and how the origin is determined:


##How Routes Get Called & Origin Resolution

###The Route Structure

The build process generates three main routes under /.well-known/workflow/v1/:

RoutePurpose
/flowExecutes workflow functions
/stepExecutes step functions
/webhook/:tokenHandles webhook callbacks

###How Routes Get Invoked

Routes are not called directly by users - they're invoked by the queue system. Here's the flow:

1. User calls start() to start a workflow
         ↓
2. World queues a message to `__wkf_workflow_{workflowName}`
         ↓
3. Queue worker picks up message
         ↓
4. Worker calls HTTP endpoint: `{baseUrl}/.well-known/workflow/v1/flow`
         ↓
5. Workflow executes, hits a step
         ↓
6. Step is queued to `__wkf_step_{stepId}`
         ↓
7. Queue worker picks up message  
         ↓
8. Worker calls HTTP endpoint: `{baseUrl}/.well-known/workflow/v1/step`

###Origin Resolution by World Type

####Local World (@workflow/world-local)

From packages/world-local/src/config.ts and queue.ts:

// Priority order for resolving base URL:
// 1. config.baseUrl (explicit override)
// 2. WORKFLOW_LOCAL_BASE_URL env var
// 3. config.port (explicit port)
// 4. PORT env var
// 5. Auto-detected port via getWorkflowPort()

The getWorkflowPort() function in @workflow/utils/get-port is particularly clever:

  • Reads /proc/net/tcp (Linux), lsof (macOS), or netstat (Windows) to find ports the process is listening on
  • If multiple ports detected, probes each with a HEAD request to /.well-known/workflow/v1/flow?__health
  • Returns the port that responds with HTTP 200
// packages/world-local/src/queue.ts
const baseUrl = await resolveBaseUrl(config);
const response = await fetch(
  `${baseUrl}/.well-known/workflow/v1/${pathname}`,  // pathname = 'step' or 'flow'
  { method: 'POST', body, ... }
);

####Vercel World (@workflow/world-vercel)

Uses Vercel Queue Service (VQS) - no direct HTTP calls needed:

  • Messages go to VQS via @vercel/queue client
  • VQS delivers to the deployment via queue triggers configured in .vc-config.json
  • The VERCEL_DEPLOYMENT_ID env var ensures messages route to the correct deployment
// packages/world-vercel/src/queue.ts
const deploymentId = opts?.deploymentId ?? process.env.VERCEL_DEPLOYMENT_ID;
const sendMessageClient = new Client({ ...baseClientOptions, deploymentId });
await sendMessageClient.send(sanitizedQueueName, encoded, { ... });

###Workflow Context URL

Inside workflow code, when you call createWebhook(), it needs to build an absolute URL. From packages/core/src/workflow.ts:

// Line 127-130
const url = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : `http://localhost:${port ?? 3000}`;

const ctx: WorkflowMetadata = {
  workflowRunId: workflowRun.runId,
  workflowStartedAt: new vmGlobalThis.Date(+startedAt),
  url,  // This is what getWorkflowMetadata().url returns
};

Then in createWebhook():

// packages/core/src/workflow/create-hook.ts
const { url } = getWorkflowMetadata();
hook.url = `${url}/.well-known/workflow/v1/webhook/${encodeURIComponent(hook.token)}`;

###Summary

WorldHow routes are calledOrigin source
LocalDirect HTTP fetch from queue worker in same processAuto-detected port, or WORKFLOW_LOCAL_BASE_URL, or PORT env
VercelVQS delivers to deployment via queue triggersVERCEL_DEPLOYMENT_ID routes to correct deployment
PostgresSimilar to local - pg-boss workers make HTTP callsSame as local (configured base URL)

The key insight is that the origin doesn't need to be publicly accessible for local development - the queue worker runs in the same Node.js process and calls localhost directly.


END OF POST

How does the bundler/plugin system work? — vercel/workflow