Skip to content

Pluggable deployment adapters: support Cloudflare, Vercel, Netlify, and custom targets #80

@southpolesteve

Description

@southpolesteve

Problem

vinext deploy is currently hard-coded to Cloudflare Workers. The entire deploy pipeline (config generation, dependency installation, build adjustments, and the deploy command itself) lives in a single deploy.ts file that only knows about wrangler, Workers, and KV.

As vinext grows, people want to deploy to other targets (Vercel, Netlify, generic Node servers). We need a way for deployment providers to plug in without forking the core.

Current state

The good news: the separation is already cleaner than it looks. Six of seven CLI commands are fully provider-agnostic today:

Command Provider-specific?
vinext init No, generates a plain vite.config.ts with just vinext()
vinext check No, pure Next.js compatibility scanner
vinext dev No, just starts Vite dev server
vinext build No, standard Vite build (with conditional codepath if CF plugin is detected)
vinext start No, pure Node.js HTTP server
vinext lint No
vinext deploy Yes, entirely Cloudflare

The Cloudflare-specific code lives in four places:

  1. deploy.ts (~780 lines): Generates wrangler.jsonc, worker entries, installs @cloudflare/vite-plugin and wrangler, builds, calls wrangler deploy
  2. vinext:cloudflare-build plugin in index.ts (lines 3058-3204): Post-build hook that injects __VINEXT_SSR_MANIFEST__ and __VINEXT_LAZY_CHUNKS__ globals into the worker entry, generates _headers file for immutable asset caching
  3. Conditional config in index.ts: A hasCloudflarePlugin boolean (detected by scanning plugin names for vite-plugin-cloudflare) that controls SSR externals, build manifest, and multi-environment behavior
  4. cloudflare/ directory: kv-cache-handler.ts (implements the already-pluggable CacheHandler interface), tpr.ts (Traffic-aware Pre-Rendering via Cloudflare analytics API)

The cache system (CacheHandler interface in shims/cache.ts) is already fully pluggable. The ISR layer (server/isr-cache.ts) is generic. The production Node server (server/prod-server.ts) has zero Cloudflare code.

Proposal: deployment adapters

Following the same pattern used by SvelteKit (@sveltejs/adapter-cloudflare, @sveltejs/adapter-vercel, etc.) and Astro (@astrojs/cloudflare, @astrojs/vercel, etc.), we introduce a deployment adapter interface.

Adapter interface (rough sketch)

interface DeployAdapter {
  /** Human-readable name, e.g. "Cloudflare Workers" */
  name: string;

  /**
   * Vite config adjustments for this target.
   * Called during vinext's config hook.
   *
   * Examples:
   *   - Cloudflare: disable SSR externals, enable build manifest
   *   - Vercel: set output directory for serverless functions
   *   - Node: no changes needed
   */
  configureVite?: (ctx: AdapterContext) => ViteConfigOverrides;

  /**
   * Additional Vite plugins required by this adapter.
   * Returned plugins are merged into the plugin array.
   *
   * Examples:
   *   - Cloudflare: returns @cloudflare/vite-plugin
   *   - Vercel: returns vercel's vite plugin or preset
   */
  vitePlugins?: (ctx: AdapterContext) => Plugin[];

  /**
   * Post-build hook. Runs after all Vite environments are built.
   * Use this for injecting globals, rewriting output, generating
   * platform-specific files, etc.
   *
   * Examples:
   *   - Cloudflare: inject __VINEXT_SSR_MANIFEST__, generate _headers
   *   - Vercel: generate .vercel/output config
   */
  postBuild?: (ctx: BuildContext) => Promise<void>;

  /**
   * Generate platform-specific config files if they don't exist.
   * Called during `vinext deploy` before the build step.
   *
   * Examples:
   *   - Cloudflare: generate wrangler.jsonc, worker/index.ts
   *   - Vercel: generate vercel.json
   *   - Netlify: generate netlify.toml
   */
  generateConfig?: (ctx: AdapterContext) => Promise<GeneratedFile[]>;

  /**
   * Generate the server entry point for this target.
   *
   * Examples:
   *   - Cloudflare: Workers fetch() handler
   *   - Vercel: serverless function handler
   *   - Node: already covered by vinext start, may be a no-op
   */
  generateServerEntry?: (ctx: AdapterContext) => Promise<GeneratedFile | null>;

  /**
   * npm packages this adapter needs installed.
   */
  dependencies?: () => { name: string; dev?: boolean }[];

  /**
   * Run the actual deployment.
   * Called during `vinext deploy` after the build completes.
   *
   * Examples:
   *   - Cloudflare: exec `wrangler deploy`
   *   - Vercel: exec `vercel deploy` or `vercel --prod`
   *   - Netlify: exec `netlify deploy --prod`
   */
  deploy: (ctx: DeployContext) => Promise<void>;
}

Package structure

Separate packages in this monorepo:

packages/
  vinext/              # Core (unchanged package name)
  adapter-cloudflare/  # @vinext/adapter-cloudflare
  adapter-vercel/      # @vinext/adapter-vercel (future)
  adapter-netlify/     # @vinext/adapter-netlify (future)
  adapter-node/        # @vinext/adapter-node (future, wraps vinext start)

User-facing config

The adapter is specified in vite.config.ts, similar to how SvelteKit does it in svelte.config.js:

import vinext from "vinext";
import cloudflare from "@vinext/adapter-cloudflare";

export default defineConfig({
  plugins: [vinext({ adapter: cloudflare() })],
});

Then vinext deploy reads the adapter from the resolved config. No CLI argument needed in the common case. If no adapter is configured, vinext deploy prints an error telling the user to pick one.

What changes in core

  1. Define the DeployAdapter interface in the vinext core package
  2. Replace hasCloudflarePlugin detection in index.ts with adapter-driven config: the adapter's configureVite() hook provides the overrides instead of vinext detecting plugins by name
  3. Extract vinext:cloudflare-build into the Cloudflare adapter's postBuild() hook
  4. Extract deploy.ts into @vinext/adapter-cloudflare (the current deploy.ts essentially becomes the adapter)
  5. Update cli.ts to load the adapter from vite config and call its deploy() method
  6. vinext build stays generic: the adapter hooks run at config time and post-build time, so vinext build doesn't need to know which adapter is in use

What stays the same

  • vinext init, vinext check, vinext dev, vinext start, vinext lint are unchanged
  • The CacheHandler interface is already pluggable (unrelated to this work)
  • server/prod-server.ts remains a pure Node server
  • server/isr-cache.ts remains generic

Open questions

How should adapters interact with existing Vite platform plugins?

Cloudflare, Vercel, and Netlify all have their own Vite plugins. Should the adapter:

  • (a) Wrap and re-export the platform's Vite plugin (adapter owns the full pipeline)
  • (b) Coexist alongside the platform's Vite plugin (adapter only handles deploy, platform plugin handles build)
  • (c) Both, with the adapter detecting whether the platform plugin is already configured

Option (c) is probably the most pragmatic. The adapter can provide the platform plugin via vitePlugins() if the user hasn't already added it manually, but also work alongside a manually configured plugin.

Should vinext start be documented as the "deploy anywhere" solution?

For targets that don't have a dedicated deploy platform (Docker, fly.io, Railway, any VPS), vinext start already gives you a Node HTTP server. Rather than building adapters for every hosting provider, we could document vinext start as the universal escape hatch and provide guidance on running it in Docker, systemd, etc.

Adapter-specific CLI flags

The current vinext deploy has Cloudflare-specific flags (--experimental-tpr, --tpr-coverage, etc.). Should adapter-specific flags be:

  • (a) Namespaced: vinext deploy --cloudflare-tpr
  • (b) Passed through: vinext deploy -- --experimental-tpr (everything after -- goes to the adapter)
  • (c) Configured in vite.config.ts instead of CLI flags: cloudflare({ tpr: { coverage: 90 } })

Option (c) feels cleanest since the adapter is already configured in vite.config.ts.

Implementation plan

  1. Define the DeployAdapter interface in packages/vinext/src/adapter.ts
  2. Create packages/adapter-cloudflare/ and extract current Cloudflare code into it
  3. Update index.ts to call adapter hooks instead of checking hasCloudflarePlugin
  4. Update cli.ts deploy command to load adapter from config
  5. Ensure all existing tests and examples still work (should be a refactor, not a behavior change)
  6. Document the adapter interface for community contributors

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions