Abstract spiceflow's React Server Components implementation so it works with both Vite and Parcel (and potentially other bundlers in the future).
The spiceflow RSC implementation has 3 layers of Vite coupling:
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 configViteDevServer,RunnableDevEnvironment— dev server runtimeserver.environments.ssr.runner.import()— loads SSR entry in devserver.environments['rsc']— accesses RSC environment module graph for CSS- Plugin hooks:
configureServer,configurePreviewServer,configEnvironment,transform,resolveId,load,writeBundle,closeBundle .vite/manifest.jsonparsing for production CSS discovery
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 |
The RSC rendering and action handling imports from @vitejs/plugin-rsc/rsc:
renderToReadableStreamcreateTemporaryReferenceSetdecodeReply,decodeAction,decodeFormState,loadServerAction
The Parcel equivalents come from react-server-dom-parcel/server.edge — same function signatures.
Both bundlers use the exact same React Flight protocol. The following are identical:
- The RSC payload format (React Flight)
rsc-html-streamfor injecting RSC payload into HTML (both use it)ReactDOMServer.renderToReadableStreamfor 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 |
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.tsxserver 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
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.
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
}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)
}
}
}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
}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.
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')
+ }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()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')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:
- Configure Parcel targets — set
react-servercontext for the server entry - Inject
"use server-entry"directive into spiceflow's app entry (or create a wrapper module that has it) - Inject
"use client-entry"into spiceflow's client entry - Resolve
virtual:bundler-adapter/*to the Parcel adapter files (Parcel resolvers) - Auto
"use client"injection — same transform as Vite but using Parcel's transformer plugin API - 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/nodedirectly and adapt spiceflow's server to callrenderRequest(higher level, less control) - Option B: Use
react-server-dom-parceldirectly 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.
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.
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.
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
- 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 | 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 |
| 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 |
| 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 |
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.