How does the bundler/plugin system work?
how does the bundler/plugin system work? all via nitro?
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:
BaseBuilder- Abstract base class with common build logic using esbuild for bundling + SWC for code transformation- Transform utilities - Pattern detection (
detectWorkflowPatterns) to identify files with"use workflow","use step"directives, or serde patterns - esbuild plugins - Discovery plugin, SWC transform plugin, pseudo-package plugin
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, orclient - Transforms directive-annotated code appropriately for each context
###Framework-Specific Integrations
Each framework has its own integration layer that uses the shared building blocks:
| Framework | Package | Transform Hook | Notes |
|---|---|---|---|
| Next.js | @workflow/next | Webpack loader + Turbopack rules | Uses custom NextBuilder, adds webpack/turbopack loader that runs SWC transform |
| Nitro | @workflow/nitro | Rollup rollup:before hook | Uses @workflow/rollup plugin, injects into Nitro's rollup config |
| Nuxt | @workflow/nuxt | Delegates to Nitro | Just configures @workflow/nitro as a Nitro module |
| SvelteKit | @workflow/sveltekit | Vite plugin | Uses @workflow/rollup transform + @workflow/vite hot-update |
| Astro | @workflow/astro | Vite plugin | Same 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
clientmode - 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/builderspackage provides the shared bundling logic that all integrations use
how do those routes get called after? how does workflow know what the origin is?
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/:
| Route | Purpose |
|---|---|
/flow | Executes workflow functions |
/step | Executes step functions |
/webhook/:token | Handles 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), ornetstat(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/queueclient - VQS delivers to the deployment via queue triggers configured in
.vc-config.json - The
VERCEL_DEPLOYMENT_IDenv 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
| World | How routes are called | Origin source |
|---|---|---|
| Local | Direct HTTP fetch from queue worker in same process | Auto-detected port, or WORKFLOW_LOCAL_BASE_URL, or PORT env |
| Vercel | VQS delivers to deployment via queue triggers | VERCEL_DEPLOYMENT_ID routes to correct deployment |
| Postgres | Similar to local - pg-boss workers make HTTP calls | Same 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