Skip to content

Instantly share code, notes, and snippets.

@remorses
Created March 6, 2026 18:17
Show Gist options
  • Select an option

  • Save remorses/a8de62cb46a9597eb7e27565181a9d6e to your computer and use it in GitHub Desktop.

Select an option

Save remorses/a8de62cb46a9597eb7e27565181a9d6e to your computer and use it in GitHub Desktop.
Spiceflow: Multi-bundler RSC abstraction plan (Vite + Parcel)

Multi-Bundler RSC Abstraction Plan

Abstract spiceflow's React Server Components implementation so it works with both Vite and Parcel (and potentially other bundlers in the future).


Current State: Where Vite is Coupled

The spiceflow RSC implementation has 3 layers of Vite coupling:

Layer 1: Build Plugin (spiceflow/src/vite.tsx)

The entire build orchestration — entry points, environments, virtual modules, manifests, CSS collection, HMR, dev server middleware. This is inherently bundler-specific and each bundler needs its own plugin file.

Key Vite-specific APIs used:

  • @vitejs/plugin-rsc — RSC plugin instantiation with entries config
  • ViteDevServer, RunnableDevEnvironment — dev server runtime
  • server.environments.ssr.runner.import() — loads SSR entry in dev
  • server.environments['rsc'] — accesses RSC environment module graph for CSS
  • Plugin hooks: configureServer, configurePreviewServer, configEnvironment, transform, resolveId, load, writeBundle, closeBundle
  • .vite/manifest.json parsing for production CSS discovery

Layer 2: Runtime Entry Points

These import from bundler-specific packages:

File Vite Import Parcel Equivalent
entry.ssr.tsx:8 createFromReadableStream from @vitejs/plugin-rsc/ssr createFromReadableStream from react-server-dom-parcel/client.edge
entry.client.tsx:5-11 createFromReadableStream, createFromFetch, createTemporaryReferenceSet, encodeReply, setServerCallback from @vitejs/plugin-rsc/browser Same functions from react-server-dom-parcel/client
entry.ssr.tsx:79-81 import.meta.viteRsc.loadBootstrapScriptContent('index') options.component.bootstrapScript (injected by Parcel runtime proxy)
entry.ssr.tsx:180-183 import.meta.viteRsc.import('./entry.rsc', {environment: 'rsc'}) Not needed — Parcel builds server as single target, direct import works
entry.rsc.tsx:14-15 import.meta.hot.accept() Parcel HMR is automatic
entry.client.tsx:98-103 import.meta.hot.on('rsc:update', ...) parcelhmrreload browser event
entry.client.tsx:106-112 vite-error-overlay custom element Parcel has its own error overlay

Layer 3: Server Actions (spiceflow.tsx:1000-1007)

The RSC rendering and action handling imports from @vitejs/plugin-rsc/rsc:

  • renderToReadableStream
  • createTemporaryReferenceSet
  • decodeReply, decodeAction, decodeFormState, loadServerAction

The Parcel equivalents come from react-server-dom-parcel/server.edgesame function signatures.


What's Shared Between Vite and Parcel

Both bundlers use the exact same React Flight protocol. The following are identical:

  • The RSC payload format (React Flight)
  • rsc-html-stream for injecting RSC payload into HTML (both use it)
  • ReactDOMServer.renderToReadableStream for SSR
  • The overall flow: RSC render -> tee stream -> SSR to HTML + inject payload -> hydrate on client
  • Server action protocol (POST with action ID, decode args, call action, return result)
  • The function signatures of the RSC APIs (renderToReadableStream, createFromReadableStream, decodeReply, etc.)

What differs:

Concern Vite Parcel
RSC renderer package react-server-dom-webpack (via @vitejs/plugin-rsc) react-server-dom-parcel
Bootstrap script injection import.meta.viteRsc.loadBootstrapScriptContent() options.component.bootstrapScript via Parcel runtime proxy
Cross-environment imports import.meta.viteRsc.import('./entry.rsc', {environment: 'rsc'}) Direct import() (single server target)
HMR events import.meta.hot.on('rsc:update', ...) parcelhmrreload browser event
CSS collection Module graph traversal + manifest parsing Parcel handles CSS automatically
Virtual modules virtual:app-entry, virtual:app-styles "use server-entry" directive + "use client-entry" directive
Entry point config Explicit entries: { rsc, ssr, client } in plugin config Discovered from directives in source code
Error overlay vite-error-overlay custom element Parcel's own error overlay

Two Approaches Considered

Approach A: Adapter Module Pattern (Recommended)

Create a thin BundlerAdapter interface that each bundler implements. The entry points import from the adapter instead of directly from bundler packages. A virtual module virtual:bundler-adapter is resolved by each bundler's plugin to the correct adapter implementation.

Pros:

  • Entry points stay mostly unchanged, just swap the import source
  • The spiceflow.tsx server action handling becomes bundler-agnostic
  • Each adapter is a thin mapping file (~30-50 lines)
  • Can add new bundlers (Turbopack, rspack, etc.) without touching core logic

Cons:

  • Needs a virtual module resolution mechanism per bundler
  • Bootstrap script and cross-environment import work fundamentally differently

Approach B: Separate Entry Points Per Bundler

Keep separate entry.ssr.vite.tsx, entry.ssr.parcel.tsx, etc. Each bundler plugin points to its own set of entries.

Pros: No abstraction layer, each entry is self-contained

Cons: Massive duplication of the 200+ line entry.ssr.tsx, hard to keep in sync

Decision: Approach A — the adapter pattern keeps the core logic DRY and makes it easy to add new bundlers.


Implementation Steps

Step 1: Create the adapter interface

New file: spiceflow/src/react/adapters/types.ts

Define 3 interfaces covering the ~15 functions that differ between Vite and Parcel:

// RSC environment (react-server conditions)
export interface RscServerAdapter {
  renderToReadableStream: (
    model: any,
    options?: {
      temporaryReferences?: any
      onPostpone?: (reason: string) => void
      onError?: (error: any) => string | void
      signal?: AbortSignal
    },
  ) => ReadableStream
  createTemporaryReferenceSet: () => any
  decodeReply: (body: string | FormData, options?: any) => Promise<any[]>
  decodeAction: (formData: FormData) => Promise<() => Promise<any>>
  decodeFormState: (result: any, formData: FormData) => Promise<any>
  loadServerAction: (id: string) => Promise<Function>
}

// SSR environment
export interface RscSsrAdapter {
  createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>
  loadBootstrapScriptContent: () => Promise<string>
  importRscEnvironment: <T>(specifier: string) => Promise<T>
}

// Browser environment
export interface RscClientAdapter {
  createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>
  createFromFetch: <T>(
    response: Promise<Response>,
    opts?: { temporaryReferences?: any },
  ) => Promise<T>
  createTemporaryReferenceSet: () => any
  encodeReply: (args: any[], opts?: { temporaryReferences?: any }) => Promise<any>
  setServerCallback: (cb: (id: string, args: any[]) => Promise<any>) => void
  onHmrUpdate?: (callback: () => void) => void
  onHmrError?: () => void
}

Step 2: Create the Vite adapter

New file: spiceflow/src/react/adapters/vite-server.ts (runs in RSC environment)

export {
  renderToReadableStream,
  createTemporaryReferenceSet,
  decodeReply,
  decodeAction,
  decodeFormState,
  loadServerAction,
} from '@vitejs/plugin-rsc/rsc'

New file: spiceflow/src/react/adapters/vite-ssr.ts (runs in SSR environment)

export { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'

export async function loadBootstrapScriptContent(): Promise<string> {
  return import.meta.viteRsc.loadBootstrapScriptContent('index')
}

export async function importRscEnvironment<T>(specifier: string): Promise<T> {
  return import.meta.viteRsc.import<T>(specifier, { environment: 'rsc' })
}

New file: spiceflow/src/react/adapters/vite-client.ts (runs in browser)

export {
  createFromReadableStream,
  createFromFetch,
  createTemporaryReferenceSet,
  encodeReply,
  setServerCallback,
} from '@vitejs/plugin-rsc/browser'

export function onHmrUpdate(callback: () => void) {
  if (import.meta.hot) {
    import.meta.hot.on('rsc:update', (e: any) => {
      console.log('[rsc:update]', e.file)
      callback()
    })
  }
}

export function onHmrError() {
  if (import.meta.env.DEV) {
    window.onerror = (_event, _source, _lineno, _colno, err) => {
      const ErrorOverlay = customElements.get('vite-error-overlay')
      if (!ErrorOverlay) return
      const overlay = new ErrorOverlay(err)
      document.body.appendChild(overlay)
    }
  }
}

Step 3: Create the Parcel adapter

New file: spiceflow/src/react/adapters/parcel-server.ts (runs in RSC environment)

export {
  renderToReadableStream,
  createTemporaryReferenceSet,
  decodeReply,
  decodeAction,
  decodeFormState,
  loadServerAction,
} from 'react-server-dom-parcel/server.edge'

New file: spiceflow/src/react/adapters/parcel-ssr.ts (runs in SSR environment)

export { createFromReadableStream } from 'react-server-dom-parcel/client.edge'

// Parcel injects bootstrap script via component proxy at build time.
// The actual script content is provided by @parcel/runtime-rsc.
export async function loadBootstrapScriptContent(): Promise<string> {
  // Parcel handles this automatically via renderRequest options.component
  // This function should not be called in Parcel — SSR is handled by @parcel/rsc/node
  throw new Error('loadBootstrapScriptContent is not used with Parcel')
}

// Parcel builds server as a single target, direct import works
export async function importRscEnvironment<T>(specifier: string): Promise<T> {
  return import(specifier)
}

New file: spiceflow/src/react/adapters/parcel-client.ts (runs in browser)

export {
  createFromReadableStream,
  createFromFetch,
  createTemporaryReferenceSet,
  encodeReply,
  setServerCallback,
} from 'react-server-dom-parcel/client'

export function onHmrUpdate(callback: () => void) {
  window.addEventListener('parcelhmrreload', (e: Event) => {
    e.preventDefault()
    callback()
  })
}

export function onHmrError() {
  // Parcel handles error overlay automatically
}

Step 4: Virtual module resolution

Update spiceflow/src/vite.tsx to resolve adapter virtual modules:

// Add to spiceflowPlugin():
createVirtualPlugin('bundler-adapter/server', () => {
  return `export * from '${resolve('adapters/vite-server')}'`
}),
createVirtualPlugin('bundler-adapter/ssr', () => {
  return `export * from '${resolve('adapters/vite-ssr')}'`
}),
createVirtualPlugin('bundler-adapter/client', () => {
  return `export * from '${resolve('adapters/vite-client')}'`
}),

For Parcel, the Parcel plugin would resolve these same virtual module names to the Parcel adapter files.

Step 5: Refactor entry.ssr.tsx

Replace Vite-specific imports:

- import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
+ import { createFromReadableStream, loadBootstrapScriptContent, importRscEnvironment } from 'virtual:bundler-adapter/ssr'

Replace import.meta.viteRsc calls:

- const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent('index')
+ const bootstrapScriptContent = await loadBootstrapScriptContent()
- async function importRscEntry() {
-   return await import.meta.viteRsc.import<typeof import('./entry.rsc.js')>(
-     './entry.rsc',
-     { environment: 'rsc' },
-   )
- }
+ async function importRscEntry() {
+   return await importRscEnvironment<typeof import('./entry.rsc.js')>('./entry.rsc')
+ }

Step 6: Refactor entry.client.tsx

Replace Vite-specific imports:

- import {
-   createFromReadableStream,
-   createFromFetch,
-   createTemporaryReferenceSet,
-   encodeReply,
-   setServerCallback,
- } from '@vitejs/plugin-rsc/browser'
+ import {
+   createFromReadableStream,
+   createFromFetch,
+   createTemporaryReferenceSet,
+   encodeReply,
+   setServerCallback,
+   onHmrUpdate,
+   onHmrError,
+ } from 'virtual:bundler-adapter/client'

Replace HMR and error overlay setup:

- if (import.meta.hot) {
-   import.meta.hot.on('rsc:update', (e) => {
-     console.log('[rsc:update]', e.file)
-     router.replace(router.location)
-   })
- }
+ onHmrUpdate(() => router.replace(router.location))

- if (import.meta.env.DEV) {
-   window.onerror = (event, source, lineno, colno, err) => {
-     const ErrorOverlay = customElements.get('vite-error-overlay')
-     if (!ErrorOverlay) return
-     const overlay = new ErrorOverlay(err)
-     document.body.appendChild(overlay)
-   }
- }
+ onHmrError()

Step 7: Refactor spiceflow.tsx server actions

Replace the bundler-specific dynamic import:

- const {
-   renderToReadableStream,
-   createTemporaryReferenceSet,
-   decodeReply,
-   decodeAction,
-   decodeFormState,
-   loadServerAction,
- } = await import('@vitejs/plugin-rsc/rsc')
+ const {
+   renderToReadableStream,
+   createTemporaryReferenceSet,
+   decodeReply,
+   decodeAction,
+   decodeFormState,
+   loadServerAction,
+ } = await import('virtual:bundler-adapter/server')

Step 8: Create the Parcel plugin

New file: spiceflow/src/parcel.ts (or separate spiceflow-parcel package)

This is the most complex step. Parcel's plugin system uses transformers, runtimes, and namers instead of Vite's hooks. The plugin needs to:

  1. Configure Parcel targets — set react-server context for the server entry
  2. Inject "use server-entry" directive into spiceflow's app entry (or create a wrapper module that has it)
  3. Inject "use client-entry" into spiceflow's client entry
  4. Resolve virtual:bundler-adapter/* to the Parcel adapter files (Parcel resolvers)
  5. Auto "use client" injection — same transform as Vite but using Parcel's transformer plugin API
  6. Production build config — output dist structure matching what the Node launcher expects

Key difference: Parcel discovers client/server boundaries from directives in source code rather than explicit entry configuration. Spiceflow's app entry would need a "use server-entry" wrapper:

// Generated wrapper for Parcel
"use server-entry";
export { default } from './actual-app-entry'

For the SSR flow, Parcel's @parcel/rsc/node already handles renderRequest + callAction which wraps the same React APIs. The question is whether to:

  • Option A: Use @parcel/rsc/node directly and adapt spiceflow's server to call renderRequest (higher level, less control)
  • Option B: Use react-server-dom-parcel directly through the adapter (lower level, keeps spiceflow in control of the rendering pipeline)

Recommended: Option B — this keeps the rendering pipeline identical across bundlers, with only the React Flight package swapped.

Step 9: Handle CSS differences

The virtual:app-styles module needs bundler-aware behavior:

  • Vite dev: Collects CSS via module graph traversal (css.tsx)
  • Vite build: Reads from .vite/manifest.json
  • Parcel: CSS is extracted and injected automatically by the bundler. No explicit collection needed.

The adapter handles this by making virtual:app-styles return [] in Parcel (since Parcel's runtime handles CSS injection). The Vite adapter keeps the current behavior.

Step 10: Update ambient type declarations

Update spiceflow/src/react/types/ambient.d.ts:

- /// <reference types="vite/client" />
- /// <reference types="@vitejs/plugin-rsc/types" />

  declare module 'virtual:app-styles' {
    const cssUrls: string[]
    export default cssUrls
  }

  declare module 'virtual:app-entry' {
    import type { Spiceflow } from 'spiceflow'
    const app: Spiceflow
    export default app
  }

+ declare module 'virtual:bundler-adapter/server' {
+   export const renderToReadableStream: any
+   export const createTemporaryReferenceSet: () => any
+   export const decodeReply: (body: any, options?: any) => Promise<any[]>
+   export const decodeAction: (formData: FormData) => Promise<() => Promise<any>>
+   export const decodeFormState: (result: any, formData: FormData) => Promise<any>
+   export const loadServerAction: (id: string) => Promise<Function>
+ }
+
+ declare module 'virtual:bundler-adapter/ssr' {
+   export const createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>
+   export function loadBootstrapScriptContent(): Promise<string>
+   export function importRscEnvironment<T>(specifier: string): Promise<T>
+ }
+
+ declare module 'virtual:bundler-adapter/client' {
+   export const createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>
+   export const createFromFetch: <T>(response: Promise<Response>, opts?: any) => Promise<T>
+   export const createTemporaryReferenceSet: () => any
+   export const encodeReply: (args: any[], opts?: any) => Promise<any>
+   export function setServerCallback(cb: (id: string, args: any[]) => Promise<any>): void
+   export function onHmrUpdate(callback: () => void): void
+   export function onHmrError(): void
+ }

Bundler-specific type references (vite/client, @vitejs/plugin-rsc/types) move into the respective adapter files.

Step 11: Add Parcel example app

New directory: example-react-parcel/

Mirrors the structure of example-react/ but uses Parcel as the bundler:

example-react-parcel/
  package.json          # Parcel targets config
  src/
    server.tsx          # Express server using spiceflow
    main.tsx            # Same app entry as example-react
    app/                # Same client components
  e2e/
    basic.test.ts       # Same e2e tests, different port

Step 12: Tests

  • Verify all existing e2e tests still pass with the Vite adapter (no regression)
  • Add equivalent e2e tests for the Parcel example
  • Add unit tests for the adapter contract (each adapter exports the right functions with the right signatures)

File Changes Summary

New files

File Purpose
spiceflow/src/react/adapters/types.ts Adapter interfaces
spiceflow/src/react/adapters/vite-server.ts Vite RSC environment adapter
spiceflow/src/react/adapters/vite-ssr.ts Vite SSR environment adapter
spiceflow/src/react/adapters/vite-client.ts Vite browser adapter
spiceflow/src/react/adapters/parcel-server.ts Parcel RSC environment adapter
spiceflow/src/react/adapters/parcel-ssr.ts Parcel SSR environment adapter
spiceflow/src/react/adapters/parcel-client.ts Parcel browser adapter
spiceflow/src/parcel.ts Parcel plugin (equivalent of vite.tsx)
example-react-parcel/ Parcel example app

Modified files

File Change
spiceflow/src/vite.tsx Add virtual:bundler-adapter/* resolution
spiceflow/src/react/entry.ssr.tsx Replace @vitejs/plugin-rsc/ssr and import.meta.viteRsc with adapter imports
spiceflow/src/react/entry.client.tsx Replace @vitejs/plugin-rsc/browser and HMR/error overlay with adapter imports
spiceflow/src/react/entry.rsc.tsx Replace import.meta.hot with adapter call (minor)
spiceflow/src/spiceflow.tsx Replace @vitejs/plugin-rsc/rsc dynamic import with adapter import
spiceflow/src/react/types/ambient.d.ts Add virtual:bundler-adapter/* module declarations

Complexity Assessment

Component Difficulty Notes
Adapter interface + types Low Just type definitions
Vite adapters (3 files) Low Mostly re-exports of existing imports
Parcel adapters (3 files) Medium Bootstrap script injection works differently
Refactor entry.ssr.tsx Medium import.meta.viteRsc calls need careful replacement
Refactor entry.client.tsx Low Straightforward import swap
Refactor spiceflow.tsx Low One import to change
Virtual module resolution in vite.tsx Low 3 more createVirtualPlugin calls
Parcel plugin (parcel.ts) High Parcel's plugin system (transformers, runtimes, namers, resolvers) is fundamentally different from Vite's. Need to map spiceflow's entry config to Parcel's directive-based model.
CSS handling Medium Different mental models (Vite: explicit collection, Parcel: automatic)
Parcel example app Medium Wiring up the new plugin end-to-end

Execution Order

Phase 1 (single PR): Decouple core from Vite

  • Steps 1-7 + Step 10
  • Creates the adapter layer and refactors all entry points
  • Vite keeps working exactly as before via the Vite adapter
  • No Parcel support yet, but the architecture is ready

Phase 2 (follow-up PR): Add Parcel support

  • Steps 3, 8, 9, 11, 12
  • Creates Parcel adapters and plugin
  • Adds example app and tests
  • This is where the real complexity lives

Starting with Phase 1 is low-risk — it's a refactor that preserves existing behavior while enabling future bundler support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment