diff --git a/AGENTS.md b/AGENTS.md index 955ffb013..1c3203d28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This monorepo contains multiple packages (see [README.md](README.md#packages) fo - `packages/plugin-react/` - Main React plugin with Babel - `packages/plugin-react-swc/` - SWC-based React plugin -- `packages/plugin-rsc/` - React Server Components ([AI guidance](packages/plugin-rsc/AGENTS.md)) +- `packages/plugin-rsc/` - React Server Components ([AI guidance](packages/plugin-rsc/AGENTS.md), [architecture](packages/plugin-rsc/docs/architecture.md)) - `packages/plugin-react-oxc/` - Deprecated (merged with plugin-react) ### Essential Setup Commands diff --git a/packages/plugin-rsc/AGENTS.md b/packages/plugin-rsc/AGENTS.md index 1b75ef7b1..81c8fae87 100644 --- a/packages/plugin-rsc/AGENTS.md +++ b/packages/plugin-rsc/AGENTS.md @@ -4,6 +4,8 @@ This document provides AI-agent-specific guidance for the React Server Component - **[README.md](README.md)** - Plugin overview, concepts, and examples - **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup and testing guidelines +- **[docs/architecture.md](docs/architecture.md)** - Build pipeline, data flow, and key components +- **[docs/bundler-comparison.md](docs/bundler-comparison.md)** - How different bundlers approach RSC ## Quick Reference for AI Agents diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index 3ed9996c8..8da42368c 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -245,6 +245,33 @@ ssrModule.renderHTML(...); export function renderHTML(...) {} ``` +#### `import.meta.viteRsc.import` + +- Type: `(specifier: string, options: { environment: string }) => Promise` + +A more ergonomic alternative to `loadModule`: + +1. No manual `rollupOptions.input` config needed - entries are auto-discovered +2. Specifier matches the path in `typeof import(...)` type annotations + +**Comparison:** + +```ts +// Before (loadModule) - requires vite.config.ts: +// environments.ssr.build.rollupOptions.input = { index: './entry.ssr.tsx' } +import.meta.viteRsc.loadModule('ssr', 'index') + +// After (import) - no config needed, auto-discovered +import.meta.viteRsc.import( + './entry.ssr.tsx', + { environment: 'ssr' }, +) +``` + +During development, this works the same as `loadModule`, using the `__VITE_ENVIRONMENT_RUNNER_IMPORT__` function to import modules in the target environment. + +During production build, the plugin auto-discovers these imports and emits them as entries in the target environment. A manifest file (`__vite_rsc_env_imports_manifest.js`) is generated to map module specifiers to their output filenames. + ### Available on `rsc` environment #### `import.meta.viteRsc.loadCss` @@ -616,6 +643,13 @@ Note that while there are official npm packages [`server-only`](https://www.npmj This build-time validation is enabled by default and can be disabled by setting `validateImports: false` in the plugin options. +## Architecture Documentation + +For developers interested in the internal architecture: + +- **[docs/architecture.md](docs/architecture.md)** - Build pipeline, data flow, and key components +- **[docs/bundler-comparison.md](docs/bundler-comparison.md)** - How different bundlers approach RSC + ## Credits This project builds on fundamental techniques and insights from pioneering Vite RSC implementations. diff --git a/packages/plugin-rsc/docs/architecture.md b/packages/plugin-rsc/docs/architecture.md new file mode 100644 index 000000000..c1f251392 --- /dev/null +++ b/packages/plugin-rsc/docs/architecture.md @@ -0,0 +1,175 @@ +# RSC Plugin Architecture + +## Overview + +The `@vitejs/plugin-rsc` implements React Server Components using Vite's multi-environment architecture. Each environment (rsc, ssr, client) has its own module graph, requiring a multi-pass build strategy. + +## Build Pipeline + +### With SSR (5-step) + +``` +rsc (scan) → ssr (scan) → rsc (real) → client → ssr (real) +``` + +| Step | Phase | Write to Disk | Purpose | +| ---- | -------- | ------------- | ---------------------------------------------------------------------------- | +| 1 | RSC scan | No | Discover `"use client"` boundaries → `clientReferenceMetaMap` | +| 2 | SSR scan | No | Discover `"use server"` boundaries → `serverReferenceMetaMap` | +| 3 | RSC real | Yes | Build server components, populate `renderedExports`, `serverChunk` | +| 4 | Client | Yes | Build client bundle using reference metadata, generate `buildAssetsManifest` | +| 5 | SSR real | Yes | Final SSR build with complete manifests | + +### Without SSR (4-step) + +``` +rsc (scan) → client (scan) → rsc (real) → client (real) +``` + +## Why This Build Order? + +1. **RSC scan first**: Must discover `"use client"` boundaries before client build knows what to bundle +2. **SSR scan second**: Must discover `"use server"` boundaries for proxy generation in both client and SSR +3. **RSC real third**: Generates proxy modules, determines which exports are actually used (`renderedExports`) +4. **Client fourth**: Needs RSC's `renderedExports` to tree-shake unused client components +5. **SSR last**: Needs complete client manifest for SSR hydration + +### Critical Dependency: RSC → SSR Scan + +The SSR scan **depends on RSC scan output**. This prevents parallelization: + +1. SSR entry imports `@vitejs/plugin-rsc/ssr` +2. `ssr.tsx` imports `virtual:vite-rsc/client-references` +3. This virtual module reads `clientReferenceMetaMap` (populated during RSC scan) +4. Client components may import `"use server"` files +5. SSR scan processes those imports, populating `serverReferenceMetaMap` + +## Data Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ RSC Scan Build │ +│ Writes: clientReferenceMetaMap (importId, exportNames) │ +│ Writes: serverReferenceMetaMap (for "use server" in RSC) │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SSR Scan Build │ +│ Writes: serverReferenceMetaMap (for "use server" in SSR) │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ RSC Real Build │ +│ Reads: clientReferenceMetaMap │ +│ Mutates: renderedExports, serverChunk on each meta │ +│ Outputs: rscBundle │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ + manager.stabilize() + (sorts clientReferenceMetaMap) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Client Build │ +│ Reads: clientReferenceMetaMap (with renderedExports) │ +│ Uses: clientReferenceGroups for chunking │ +│ Outputs: buildAssetsManifest, copies RSC assets │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SSR Real Build │ +│ Reads: serverReferenceMetaMap │ +│ Final output with assets manifest │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### RscPluginManager + +Central state manager shared across all build phases: + +```typescript +class RscPluginManager { + server: ViteDevServer + config: ResolvedConfig + rscBundle: Rollup.OutputBundle + buildAssetsManifest: AssetsManifest | undefined + isScanBuild: boolean = false + + // Reference tracking + clientReferenceMetaMap: Record = {} + clientReferenceGroups: Record = {} + serverReferenceMetaMap: Record = {} + serverResourcesMetaMap: Record = {} +} +``` + +### Client Reference Discovery + +When RSC transform encounters `"use client"`: + +1. Parse exports from the module +2. Generate a unique `referenceKey` (hash of module ID) +3. Store in `clientReferenceMetaMap`: + - `importId`: Module ID for importing + - `referenceKey`: Unique identifier + - `exportNames`: List of exports + - `renderedExports`: Exports actually used (populated during real build) + - `serverChunk`: Which RSC chunk imports this (for grouping) + +### Server Reference Discovery + +When transform encounters `"use server"`: + +1. Parse exported functions +2. Generate reference IDs +3. Store in `serverReferenceMetaMap` +4. Generate proxy module that calls server via RPC + +### Virtual Modules + +Key virtual modules used in the build: + +| Virtual Module | Purpose | +| ------------------------------------------------- | ----------------------------------------------- | +| `virtual:vite-rsc/client-references` | Entry point importing all client components | +| `virtual:vite-rsc/client-references/group/{name}` | Grouped client components for code splitting | +| `virtual:vite-rsc/assets-manifest` | Client asset manifest for SSR | +| `virtual:vite-rsc/rpc-client` | Dev-mode RPC client for cross-environment calls | + +### Cross-Environment Module Loading + +`import.meta.viteRsc.loadModule(environment, entryName)` enables loading modules from other environments: + +**Dev mode:** + +```typescript +globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__(environmentName, resolvedId) +``` + +**Build mode:** + +- Emits marker during transform +- `renderChunk` resolves to relative import path between output directories + +## Key Code Locations + +| Component | Location | +| ----------------------------- | -------------------------- | +| Manager definition | `src/plugin.ts:112-148` | +| Build orchestration | `src/plugin.ts:343-429` | +| clientReferenceMetaMap writes | `src/plugin.ts:1386` | +| serverReferenceMetaMap writes | `src/plugin.ts:1817, 1862` | +| Scan strip plugin | `src/plugins/scan.ts` | +| Cross-env module loading | `src/plugin.ts:824-916` | + +## Virtual Module Resolution + +Virtual modules with `\0` prefix need special handling: + +1. Vite convention: `\0` prefix marks virtual modules +2. When used as import specifiers, `\0` must be stripped +3. CSS requests get `?direct` query added by Vite +4. The `resolved-id-proxy` plugin handles query stripping + +See `src/plugins/resolved-id-proxy.ts` for implementation. diff --git a/packages/plugin-rsc/docs/bundler-comparison.md b/packages/plugin-rsc/docs/bundler-comparison.md new file mode 100644 index 000000000..faa900c79 --- /dev/null +++ b/packages/plugin-rsc/docs/bundler-comparison.md @@ -0,0 +1,174 @@ +# RSC Bundler Architecture Comparison + +How different bundlers/frameworks handle the architectural complexity of React Server Components. + +## The Core Challenge + +RSC requires discovering two types of references at build time: + +1. **Client references** (`"use client"`) - components that run on the client +2. **Server references** (`"use server"`) - functions callable from client + +The challenge: server references are often **imported by client components**, creating a dependency: + +``` +Server components → discover client boundaries → client components → discover server references +``` + +## Architectural Approaches + +### 1. Multi-Graph / Multi-Pass (Vite RSC Plugin) + +**Approach**: Separate module graphs per environment, sequential scan phases. + +``` +RSC scan → SSR scan → RSC build → Client build → SSR build +``` + +**How it works**: + +- Each environment (rsc, ssr, client) has its own module graph +- RSC scan populates `clientReferenceMetaMap` +- SSR scan reads this via `virtual:vite-rsc/client-references` to discover server references +- Sequential dependency prevents parallelization + +**Trade-offs**: + +- Clean separation of concerns +- Works with existing Vite environment API +- Multiple Rollup orchestration cycles +- Cannot parallelize scan phases (architectural dependency) + +### 2. Unified Graph with Transitions (Turbopack/Next.js) + +**Approach**: Single compiler with environment "transitions" at module boundaries. + +**How it works**: + +> "We can mark an import as a transition from server to browser or from browser to server. This is what allows Turbopack to efficiently bundle Server Components and Client Components, as well as Server Functions imported from Client Components." + +- Single unified graph for all environments +- `"use client"` creates a transition point, not a separate graph +- No separate scan phase needed - references discovered during single traversal + +**Trade-offs**: + +- Single compilation pass +- No coordination between compiler processes +- Better debugging (single source of truth) +- Requires bundler-level architecture changes (Rust rewrite) + +**Source**: [Turbopack Documentation](https://nextjs.org/docs/app/api-reference/turbopack) + +### 3. Unified Graph with Environments (Parcel) + +**Approach**: Single module graph spanning environments, environment property per module. + +**How it works**: + +> "Unlike most other bundlers, Parcel has a single unified module graph spanning across environments rather than splitting each environment into a separate build. This enables code splitting to span environments too." + +- Each module has an associated environment (server, react-client, etc.) +- `"use client"` transforms imports to Client References at boundary +- Single compilation discovers all references + +**Trade-offs**: + +- Single compilation pass +- Cross-environment code splitting +- Environment-aware from v2 (2021) +- Different mental model than traditional bundlers + +**Source**: [How Parcel bundles React Server Components](https://devongovett.me/blog/parcel-rsc.html) + +### 4. Plugin-Based Discovery (Webpack) + +**Approach**: Webpack plugin generates client manifest during standard compilation. + +**How it works**: + +- `react-server-dom-webpack/plugin` scans for `"use client"` directives +- Generates `react-client-manifest.json` with module IDs, chunks, exports +- Server uses manifest to create Client References +- Client uses manifest to load chunks on demand + +**Trade-offs**: + +- Integrates with existing Webpack ecosystem +- Leverages Webpack's chunk loading runtime +- Requires framework-level orchestration (Next.js handles multi-environment) + +**Source**: [react-server-dom-webpack](https://www.npmjs.com/package/react-server-dom-webpack) + +### 5. Layers (Rspack) + +**Approach**: Using "layers" feature to implement RSC in a Webpack-compatible way. + +**How it works**: + +- Rspack 1.0.0-beta.1 introduced "layers" support +- Layers allow frameworks to implement RSC environment separation +- Built-in RSC support on roadmap, inspired by Parcel + +**Status**: In development, not yet fully built-in. + +**Source**: [Rspack Roadmap](https://rspack.rs/misc/planning/roadmap) + +## Key Architectural Insight + +### Why Unified Graph Avoids Multi-Pass + +In a **multi-graph** approach (Vite): + +``` +Graph 1 (RSC): server.tsx → client.tsx (stops at boundary) +Graph 2 (SSR): needs to know about client.tsx → action.tsx + +Problem: Graph 2 can't start until Graph 1 identifies boundaries +Solution: Sequential scan phases +``` + +In a **unified graph** approach (Parcel/Turbopack): + +``` +Single Graph: server.tsx → client.tsx[transition] → action.tsx + +All modules in one graph with environment transitions at boundaries +No sequential dependency - discovered in single traversal +``` + +The unified approach treats `"use client"` as a **transition annotation** rather than a **graph boundary**. + +## Comparison Table + +| Bundler | Graph Model | Passes | Parallelizable | Complexity | +| --------- | ---------------------- | ------------------- | ---------------------- | ------------ | +| Vite RSC | Multi-graph | 5 (with SSR) | No (architectural dep) | Medium | +| Turbopack | Unified + transitions | 1 | N/A (single pass) | High (Rust) | +| Parcel | Unified + environments | 1 | N/A (single pass) | Medium | +| Webpack | Plugin-based | Framework-dependent | Framework-dependent | Low (plugin) | +| Rspack | Layers (WIP) | TBD | TBD | Medium | + +## Implications for Vite RSC Plugin + +The multi-pass approach is a consequence of Vite's environment API design, where each environment has its own module graph. This is fundamentally different from Parcel/Turbopack's unified graph. + +**Potential future optimizations**: + +1. **Cache scan results** - Skip scan phases on incremental builds if references unchanged +2. **Skip SSR scan** - For apps without `"use server"` (rare, ~12% of apps) +3. **Vite architecture evolution** - If Vite adopts unified graph concepts, could enable single-pass + +**Not possible without architectural changes**: + +- Parallel scan phases (SSR scan depends on RSC scan output) +- Single-pass compilation (requires unified graph) + +## References + +- [Why Does RSC Integrate with a Bundler?](https://overreacted.io/why-does-rsc-integrate-with-a-bundler/) - Dan Abramov +- [How Parcel bundles React Server Components](https://devongovett.me/blog/parcel-rsc.html) - Devon Govett +- [Turbopack Documentation](https://nextjs.org/docs/app/api-reference/turbopack) - Next.js +- [Parcel RSC Recipe](https://parceljs.org/recipes/rsc/) - Parcel +- [react-server-dom-webpack](https://www.npmjs.com/package/react-server-dom-webpack) - React +- [Waku Migration to @vitejs/plugin-rsc](https://waku.gg/blog/migration-to-vite-plugin-rsc) - Waku diff --git a/packages/plugin-rsc/docs/notes/2026-01-16-plugin-refactor.md b/packages/plugin-rsc/docs/notes/2026-01-16-plugin-refactor.md new file mode 100644 index 000000000..fb8151ad8 --- /dev/null +++ b/packages/plugin-rsc/docs/notes/2026-01-16-plugin-refactor.md @@ -0,0 +1,88 @@ +# Refactor: Split plugin.ts (~2520 lines) + +## Problem + +`packages/plugin-rsc/src/plugin.ts` is too large (~25k tokens) to fit in context. + +## Current Structure + +### Named functions (already defined but inline) + +| Function | Lines | Size | +| ------------------------------- | --------- | ---------- | +| `vitePluginUseClient` | 1320-1647 | ~330 lines | +| `vitePluginRscCss` | 2125-2520 | ~400 lines | +| `vitePluginUseServer` | 1803-1940 | ~140 lines | +| `vitePluginDefineEncryptionKey` | 1736-1800 | ~65 lines | +| `customOptimizerMetadataPlugin` | 1647-1736 | ~90 lines | +| `globalAsyncLocalStoragePlugin` | 1292-1318 | ~25 lines | +| Asset utils | 1995-2120 | ~130 lines | + +### Inline plugins in `vitePluginRsc` return array (405-1287) + +| Plugin name | Lines | Size | +| ---------------------------------- | --------- | ---------- | +| config/environments/buildApp | 500-629 | ~130 lines | +| configureServer/hotUpdate | 645-862 | ~220 lines | +| react-server-dom-webpack-alias | 864-886 | ~25 lines | +| load-environment-module | 887-981 | ~100 lines | +| load-module-dev-proxy + rpc-client | 982-1059 | ~80 lines | +| assets-manifest | 1060-1173 | ~115 lines | +| bootstrap-script-content | 1174-1275 | ~100 lines | + +## Extraction Plan + +### Phase 1: Extract largest named functions (~730 lines) + +1. `plugins/use-client.ts` - `vitePluginUseClient` (~330 lines) +2. `plugins/rsc-css.ts` - `vitePluginRscCss` + `transformRscCssExport` + CSS utils (~400 lines) + +### Phase 2: Extract remaining named functions (~320 lines) + +3. `plugins/use-server.ts` - `vitePluginUseServer` (~140 lines) +4. `plugins/assets.ts` - `assetsURL`, `assetsURLOfDeps`, `mergeAssetDeps`, `collectAssetDeps`, `collectAssetDepsInner`, `RuntimeAsset`, `serializeValueWithRuntime` (~130 lines) +5. `plugins/encryption.ts` - `vitePluginDefineEncryptionKey` (~65 lines) + +### Phase 3: Extract inline plugins (~550 lines) + +6. `plugins/hmr.ts` - configureServer, configurePreviewServer, hotUpdate (~220 lines) +7. `plugins/assets-manifest.ts` - virtual:vite-rsc/assets-manifest plugin (~115 lines) +8. `plugins/load-module.ts` - load-environment-module + dev-proxy + rpc-client (~180 lines) +9. `plugins/bootstrap.ts` - bootstrap-script-content plugins (~100 lines) + +### Phase 4: Consider further splits + +10. `plugins/config.ts` - environment config generation (~130 lines) +11. Move `RscPluginManager` class to `manager.ts` + +## Dependencies to Watch + +- Most plugins need `manager: RscPluginManager` +- Some plugins need `rscPluginOptions` +- `vitePluginUseClient` needs `customOptimizerMetadataPlugin` (extract together or keep dep) +- Asset utils used by assets-manifest plugin + +## Target Structure + +``` +src/ + plugin.ts (~500 lines - main exports, manager, buildApp orchestration) + plugins/ + use-client.ts (~330 lines) + use-server.ts (~140 lines) + rsc-css.ts (~400 lines) + assets.ts (~130 lines) + assets-manifest.ts(~115 lines) + hmr.ts (~220 lines) + load-module.ts (~180 lines) + bootstrap.ts (~100 lines) + encryption.ts (~65 lines) + ... (existing files) +``` + +## Estimated Reduction + +- Phase 1: ~730 lines (29%) +- Phase 2: ~320 lines (13%) +- Phase 3: ~550 lines (22%) +- Total: ~1600 lines (64%), leaving ~900 lines in plugin.ts diff --git a/packages/plugin-rsc/docs/notes/2026-01-16-vitersc-import.md b/packages/plugin-rsc/docs/notes/2026-01-16-vitersc-import.md new file mode 100644 index 000000000..1f69a5d3e --- /dev/null +++ b/packages/plugin-rsc/docs/notes/2026-01-16-vitersc-import.md @@ -0,0 +1,449 @@ +# Plan: `import.meta.viteRsc.import()` Implementation + +## Overview + +Implement `import.meta.viteRsc.import(specifier, { environment })` as a more ergonomic alternative to `import.meta.viteRsc.loadModule(environmentName, entryName)`. + +### API Comparison + +**Current (loadModule):** + +```ts +const ssrModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') +>('ssr', 'index') +``` + +**New (import):** + +```ts +const ssrModule = await import.meta.viteRsc.import< + typeof import('./entry.ssr.tsx') +>('./entry.ssr', { environment: 'ssr' }) +``` + +### Key Differences + +1. Takes module specifier (relative path) instead of entry name +2. Environment is passed via options object +3. Better DX: specifier matches the `typeof import(...)` type annotation + +## Implementation + +File: `packages/plugin-rsc/src/plugins/import-environment.ts` (exists, needs completion) + +Current state: draft copy of `loadModule` logic, missing imports and proper parameterization. + +### Plugin Structure + +```ts +import assert from 'node:assert' +import path from 'node:path' +import MagicString from 'magic-string' +import { stripLiteral } from 'strip-literal' +import type { Plugin, ViteDevServer, ResolvedConfig } from 'vite' +import { evalValue, normalizeRelativePath } from './utils' + +interface PluginManager { + server: ViteDevServer + config: ResolvedConfig +} + +export function vitePluginImportEnvironment(manager: PluginManager): Plugin { + return { + name: 'rsc:import-environment', + async transform(code, id) { + if (!code.includes('import.meta.viteRsc.import')) return + // ... implementation + }, + renderChunk(code, chunk) { + if (!code.includes('__vite_rsc_import__')) return + // ... implementation + }, + } +} +``` + +### Transform Logic + +Pattern: `import.meta.viteRsc.import('./entry.ssr', { environment: 'ssr' })` + +```ts +for (const match of stripLiteral(code).matchAll( + /import\.meta\.viteRsc\.import\(([\s\S]*?)\)/dg +)) { + const argCode = code.slice(...match.indices![1]!).trim() + // Parse: "('./entry.ssr', { environment: 'ssr' })" + const [specifier, { environment: environmentName }] = evalValue(`[${argCode}]`) + + // Resolve specifier relative to importer + const targetEnv = manager.server.environments[environmentName] + const resolved = await targetEnv.pluginContainer.resolveId(specifier, id) + + if (this.environment.mode === 'dev') { + replacement = `globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__(${JSON.stringify(environmentName)}, ${JSON.stringify(resolved.id)})` + } else { + // Build: find entry name from resolved ID + const entryName = findEntryName(targetEnv.config, resolved.id) + replacement = `"__vite_rsc_import__:${JSON.stringify({...})}__"` + } +} +``` + +### Auto-Discovery Pattern (like "use client") + +Similar to how `clientReferenceMetaMap` tracks "use client" modules that become client build entries. + +**Manager tracking:** + +```ts +// In RscPluginManager +environmentImportMetaMap: Record = {} + +type EnvironmentImportMeta = { + resolvedId: string // Absolute path of target module + targetEnv: string // e.g., 'ssr' + sourceEnv: string // e.g., 'rsc' + specifier: string // Original specifier for display + entryName: string // Derived entry name for output +} +``` + +**Discovery during transform:** + +```ts +// In rsc environment, when we see: +// import.meta.viteRsc.import('./entry.ssr', { environment: 'ssr' }) + +const resolved = await targetEnv.pluginContainer.resolveId(specifier, importer) +const entryName = deriveEntryName(specifier) // e.g., 'entry.ssr' from './entry.ssr.tsx' + +manager.environmentImportMetaMap[resolved.id] = { + resolvedId: resolved.id, + targetEnv: environmentName, + sourceEnv: this.environment.name, + specifier, + entryName, +} +``` + +**Build phase - virtual entry:** +Similar to `virtual:vite-rsc/client-references`, create a virtual module that re-exports discovered entries: + +```ts +// virtual:vite-rsc/env-imports/{targetEnv} +// Loaded during target environment build + +load(id) { + if (id === '\0virtual:vite-rsc/env-imports/ssr') { + const entries = Object.values(manager.environmentImportMetaMap) + .filter(m => m.targetEnv === 'ssr') + + let code = '' + for (const meta of entries) { + // Export each entry module + code += `export * as ${meta.entryName} from ${JSON.stringify(meta.resolvedId)};\n` + } + return code + } +} +``` + +Or simpler: just ensure the entries are in `rollupOptions.input`: + +```ts +// During buildStart of target environment +buildStart() { + if (this.environment.name === 'ssr') { + const entries = Object.values(manager.environmentImportMetaMap) + .filter(m => m.targetEnv === 'ssr') + for (const meta of entries) { + // Add to input dynamically? Or use emitFile? + } + } +} +``` + +**Simplest approach:** Use `this.emitFile` in target environment build: + +```ts +// In target environment (ssr) buildStart +for (const meta of discoveredEntries) { + this.emitFile({ + type: 'chunk', + id: meta.resolvedId, + name: meta.entryName, + }) +} +``` + +### renderChunk Logic + +```ts +for (const match of code.matchAll( + /["']__vite_rsc_import__:([\s\S]*?)__["']/dg, +)) { + const { fromEnv, toEnv, entryName } = JSON.parse(match[1]) + // Look up actual output filename from emitted chunks + const targetFileName = `${entryName}.js` // or lookup from manifest + const importPath = normalizeRelativePath( + path.relative( + path.join( + config.environments[fromEnv].build.outDir, + chunk.fileName, + '..', + ), + path.join(config.environments[toEnv].build.outDir, targetFileName), + ), + ) + s.overwrite(start, end, `(import(${JSON.stringify(importPath)}))`) +} +``` + +## Build Pipeline Integration + +Per `docs/architecture.md`, the build order is: + +``` +rsc (scan) → ssr (scan) → rsc (real) → client → ssr (real) +``` + +Integration points: + +1. **RSC scan** - discovers `import.meta.viteRsc.import` calls, populates `environmentImportMetaMap` +2. **SSR scan** - can already use discovered entries (SSR scan runs after RSC scan) +3. **RSC real** - transform rewrites to marker with entry names +4. **SSR real** - emits discovered entries via `this.emitFile` in `buildStart` +5. **renderChunk** - resolves markers to relative import paths + +Similar to `clientReferenceMetaMap` pattern: + +- Scan phase populates the map +- Real build phases use the map to generate code/entries + +## Side Note: Future API Considerations + +This API is a stopgap until Vite supports plugin API for import attributes (`import("...", { with: { ... } })`). + +**Current:** + +```ts +import.meta.viteRsc.import('./entry.ssr', { environment: 'ssr' }) +``` + +**Future (when Vite supports):** + +```ts +import('./entry.ssr', { with: { environment: 'ssr' } }) +``` + +**Asset loading extension:** If we later want to replace `loadBootstrapScriptContent` with a similar pattern, we could use a separate method to avoid overloading: + +```ts +import.meta.viteRsc.import('./entry.ssr', { environment: 'ssr' }) // returns module +import.meta.viteRsc.importAsset('./entry.browser', { asset: 'client' }) // returns string +``` + +This avoids breaking changes and keeps each method's return type clear. + +## Manifest-Based Approach (Preferred) + +The initial implementation hardcoded output filenames (`${entryName}.js`), which breaks with custom `entryFileNames` config. A manifest approach solves this. + +### Problem with Hardcoded Filenames + +```ts +// renderChunk - current implementation +const targetFileName = `${entryName}.js` // ❌ Breaks with custom entryFileNames +``` + +Build order means RSC builds before SSR: + +``` +rsc (real) → client → ssr (real) + ↑ ↑ + RSC renderChunk SSR output filenames known +``` + +### Solution: Manifest with Static Imports + +Generate a manifest file with **static import functions** that bundlers can analyze: + +```ts +// __vite_rsc_env_imports_manifest.js (generated in buildApp after SSR build) +export default { + '/abs/path/to/entry.ssr.tsx': () => import('../ssr/entry.ssr.js'), +} +``` + +Transform emits manifest lookup: + +```ts +// Original: +await import.meta.viteRsc + .import('./entry.ssr', { environment: 'ssr' })( + // Build transform to: + await import('./__vite_rsc_env_imports_manifest.js'), + ) + .default['/abs/path/to/entry.ssr.tsx']() +``` + +### Why Static Import Functions? + +Dynamic imports like `import(manifest['key'])` break post-bundling and static analysis. By using functions with static import strings, bundlers can: + +- Analyze the dependency graph +- Apply optimizations (tree-shaking, code-splitting) +- Work correctly with further bundling + +### Implementation Changes + +**1. Transform (in RSC):** + +- Dev: unchanged (`globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__`) +- Build: emit manifest lookup instead of markers + +```ts +// Build mode transform +const manifestImport = `await import('./__vite_rsc_env_imports_manifest.js')` +replacement = `(${manifestImport}).default[${JSON.stringify(resolvedId)}]()` +``` + +**2. Remove renderChunk:** + +- No longer needed for this feature (markers eliminated) + +**3. SSR generateBundle:** + +- Track output filenames for discovered entries + +```ts +generateBundle(options, bundle) { + for (const [fileName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'chunk' && chunk.isEntry) { + const resolvedId = chunk.facadeModuleId + if (resolvedId && resolvedId in manager.environmentImportMetaMap) { + manager.environmentImportOutputMap[resolvedId] = fileName + } + } + } +} +``` + +**4. buildApp - writeEnvironmentImportsManifest:** + +- Generate manifest after SSR build completes +- Calculate relative paths from manifest location to target chunks + +```ts +function writeEnvironmentImportsManifest() { + const rscOutDir = config.environments.rsc.build.outDir + const manifestPath = path.join( + rscOutDir, + '__vite_rsc_env_imports_manifest.js', + ) + + let code = 'export default {\n' + for (const [resolvedId, meta] of Object.entries( + manager.environmentImportMetaMap, + )) { + const outputFileName = manager.environmentImportOutputMap[resolvedId] + const targetOutDir = config.environments[meta.targetEnv].build.outDir + const relativePath = normalizeRelativePath( + path.relative(rscOutDir, path.join(targetOutDir, outputFileName)), + ) + code += ` ${JSON.stringify(resolvedId)}: () => import(${JSON.stringify(relativePath)}),\n` + } + code += '}\n' + + fs.writeFileSync(manifestPath, code) +} +``` + +### Bidirectional Support + +Both directions are supported: + +- **RSC → SSR**: `import('./entry.ssr', { environment: 'ssr' })` in RSC code +- **SSR → RSC**: `import('./entry.rsc', { environment: 'rsc' })` in SSR code + +This is similar to "use client" / "use server" discovery - each scan phase can discover entries for other environments. + +**Key insight**: Entry injection must happen AFTER both scan phases but BEFORE real builds. + +### Data Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ RSC Scan │ +│ transform: discover viteRsc.import → populate environmentImportMetaMap│ +│ (discovers RSC → SSR imports) │ +└─────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SSR Scan │ +│ transform: discover viteRsc.import → populate environmentImportMetaMap│ +│ (discovers SSR → RSC imports) │ +└─────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Inject Discovered Entries (in buildApp, after both scans) │ +│ for each meta in environmentImportMetaMap: │ +│ inject meta.resolvedId into target environment's rollupOptions.input│ +└─────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────┐ +│ RSC Real Build │ +│ transform: emit manifest lookup code │ +│ generateBundle: track resolvedId → outputFileName (for SSR → RSC) │ +└─────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Client Build │ +└─────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────┐ +│ SSR Real Build │ +│ transform: emit manifest lookup code │ +│ generateBundle: track resolvedId → outputFileName (for RSC → SSR) │ +└─────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────┐ +│ buildApp Post-Build │ +│ writeEnvironmentImportsManifest: │ +│ - Write manifest to RSC output (for RSC → SSR imports) │ +│ - Write manifest to SSR output (for SSR → RSC imports) │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Manifest Per Source Environment + +Each source environment gets its own manifest with imports pointing to target environments: + +```ts +// dist/rsc/__vite_rsc_env_imports_manifest.js (for RSC → SSR) +export default { + '/abs/path/entry.ssr.tsx': () => import('../ssr/entry.ssr.js'), +} + +// dist/ssr/__vite_rsc_env_imports_manifest.js (for SSR → RSC) +export default { + '/abs/path/entry.rsc.tsx': () => import('../rsc/index.js'), +} +``` + +## Implementation Steps + +1. [x] Add `environmentImportMetaMap` to RscPluginManager +2. [x] Clean up `import-environment.ts`: add imports, parameterize with manager +3. [x] Implement transform to discover and track imports +4. [x] Inject discovered entries into target environment's input +5. [x] Split entry injection: after RSC scan (RSC → other), after SSR scan (SSR → other) +6. [x] Update transform to emit manifest lookup (build mode) +7. [x] Remove renderChunk marker replacement, add resolveId to mark manifest as external +8. [x] Add `environmentImportOutputMap` to track resolvedId → outputFileName +9. [x] Add generateBundle hook to populate output map (in both RSC and SSR) +10. [x] Add `writeEnvironmentImportsManifest` in buildApp (per source environment) +11. [x] Test with basic example (RSC → SSR) - all 38 starter tests pass +12. [ ] Test bidirectional (SSR → RSC) if applicable +13. [ ] Update documentation diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 88dc58720..a81d63019 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -244,6 +244,13 @@ function defineTest(f: Fixture) { await page.getByRole('button', { name: 'client-counter: 1' }).click() }) + test('import environment', async ({ page }) => { + await page.goto(f.url()) + await expect(page.getByTestId('import-environment')).toHaveText( + '[test-import-environment: test-ssr-module: test-rsc-module]', + ) + }) + test('server action @js', async ({ page }) => { await page.goto(f.url()) await waitForHydration(page) diff --git a/packages/plugin-rsc/e2e/import-environment.test.ts b/packages/plugin-rsc/e2e/import-environment.test.ts new file mode 100644 index 000000000..e0dd0a717 --- /dev/null +++ b/packages/plugin-rsc/e2e/import-environment.test.ts @@ -0,0 +1,46 @@ +import { test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' + +test.describe('viteRsc.import', () => { + const root = 'examples/e2e/temp/vitersc-import' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/framework/entry.rsc.tsx': { + edit: (s) => + s.replace( + `\ + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') +`, + `\ + const ssrEntryModule = await import.meta.viteRsc.import< + typeof import('./entry.ssr.tsx') + >('./entry.ssr.tsx', { environment: 'ssr' }) +`, + ), + }, + 'vite.config.base.ts': { cp: 'vite.config.ts' }, + 'vite.config.ts': /* js */ ` + import baseConfig from './vite.config.base.ts' + delete baseConfig.environments.ssr.build.rollupOptions.input; + export default baseConfig; + `, + }, + }) + }) + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineStarterTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + }) +}) diff --git a/packages/plugin-rsc/examples/basic/src/routes/import-environment/rsc.tsx b/packages/plugin-rsc/examples/basic/src/routes/import-environment/rsc.tsx new file mode 100644 index 000000000..cf8a561eb --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/import-environment/rsc.tsx @@ -0,0 +1 @@ +export const testRscModule = async () => 'test-rsc-module' diff --git a/packages/plugin-rsc/examples/basic/src/routes/import-environment/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/import-environment/server.tsx new file mode 100644 index 000000000..c7021f11d --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/import-environment/server.tsx @@ -0,0 +1,12 @@ +export async function TestImportEnvironment() { + const { testSsrModule } = await import.meta.viteRsc.import< + typeof import('./ssr') + >('./ssr.tsx', { environment: 'ssr' }) + const html = await testSsrModule() + return ( +
+ [test-import-environment:{' '} + ] +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/import-environment/ssr.tsx b/packages/plugin-rsc/examples/basic/src/routes/import-environment/ssr.tsx new file mode 100644 index 000000000..dc9ea4e5f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/import-environment/ssr.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import { renderToString } from 'react-dom/server' + +// SSR-only: useState and renderToString are not available in RSC environment + +function ClientComponent() { + const [state] = useState('test-ssr-module') + return <>{state} +} + +export async function testSsrModule() { + const { testRscModule } = await import.meta.viteRsc.import< + typeof import('./rsc') + >('./rsc.tsx', { environment: 'rsc' }) + const value = await testRscModule() + return renderToString( + <> + : {value} + , + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/root.tsx b/packages/plugin-rsc/examples/basic/src/routes/root.tsx index f630d5e29..24cc8e637 100644 --- a/packages/plugin-rsc/examples/basic/src/routes/root.tsx +++ b/packages/plugin-rsc/examples/basic/src/routes/root.tsx @@ -34,6 +34,7 @@ import { TestHmrSharedServer } from './hmr-shared/server' import { TestHmrSwitchClient } from './hmr-switch/client' import { TestHmrSwitchServer } from './hmr-switch/server' import { TestHydrationMismatch } from './hydration-mismatch/server' +import { TestImportEnvironment } from './import-environment/server' import { TestImportMetaGlob } from './import-meta-glob/server' import { TestLazyCssClientToClient } from './lazy-css/client-to-client' import { TestLazyCssServerToClient } from './lazy-css/server-to-client' @@ -111,6 +112,7 @@ export function Root(props: { url: URL }) { + diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 270b15162..15c6a5c56 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -27,6 +27,12 @@ import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' +import { + ensureEnvironmentImportsEntryFallback, + vitePluginImportEnvironment, + writeEnvironmentImportsManifest, + type EnvironmentImportMeta, +} from './plugins/import-environment' import { vitePluginResolvedIdProxy, withResolvedIdProxy, @@ -112,7 +118,7 @@ export type { RscPluginManager } class RscPluginManager { server!: ViteDevServer config!: ResolvedConfig - rscBundle!: Rollup.OutputBundle + bundles: Record = {} buildAssetsManifest: AssetsManifest | undefined isScanBuild: boolean = false clientReferenceMetaMap: Record = {} @@ -120,6 +126,16 @@ class RscPluginManager { {} serverReferenceMetaMap: Record = {} serverResourcesMetaMap: Record = {} + environmentImportMetaMap: Record< + string, // sourceEnv + Record< + string, // targetEnv + Record< + string, // resolvedId + EnvironmentImportMeta + > + > + > = {} stabilize(): void { // sort for stable build @@ -145,6 +161,10 @@ class RscPluginManager { fs.writeFileSync(manifestPath, assetsManifestCode) } } + + writeEnvironmentImportsManifest(): void { + writeEnvironmentImportsManifest(this) + } } export type RscPluginOptions = { @@ -370,6 +390,7 @@ export default function vitePluginRsc( logStep('[4/4] build client environment...') await builder.build(builder.environments.client!) manager.writeAssetsManifest(['rsc']) + manager.writeEnvironmentImportsManifest() return } @@ -388,6 +409,7 @@ export default function vitePluginRsc( ) // rsc -> ssr -> rsc -> client -> ssr + ensureEnvironmentImportsEntryFallback(builder.config) manager.isScanBuild = true builder.environments.rsc!.config.build.write = false builder.environments.ssr!.config.build.write = false @@ -426,6 +448,7 @@ export default function vitePluginRsc( } manager.writeAssetsManifest(['ssr', 'rsc']) + manager.writeEnvironmentImportsManifest() } let hasReactServerDomWebpack = false @@ -1021,9 +1044,7 @@ export function createRpcClient(params) { // client build generateBundle(_options, bundle) { // copy assets from rsc build to client build - if (this.environment.name === 'rsc') { - manager.rscBundle = bundle - } + manager.bundles[this.environment.name] = bundle if (this.environment.name === 'client') { const filterAssets = @@ -1033,7 +1054,7 @@ export function createRpcClient(params) { typeof rscBuildOptions.manifest === 'string' ? rscBuildOptions.manifest : rscBuildOptions.manifest && '.vite/manifest.json' - for (const asset of Object.values(manager.rscBundle)) { + for (const asset of Object.values(manager.bundles['rsc']!)) { if (asset.fileName === rscViteManifest) continue if (asset.type === 'asset' && filterAssets(asset.fileName)) { this.emitFile({ @@ -1045,7 +1066,7 @@ export function createRpcClient(params) { } const serverResources: Record = {} - const rscAssetDeps = collectAssetDeps(manager.rscBundle) + const rscAssetDeps = collectAssetDeps(manager.bundles['rsc']!) for (const [id, meta] of Object.entries( manager.serverResourcesMetaMap, )) { @@ -1212,6 +1233,7 @@ import.meta.hot.on("rsc:update", () => { }, ), ...vitePluginRscMinimal(rscPluginOptions, manager), + ...vitePluginImportEnvironment(manager), ...vitePluginFindSourceMapURL(), ...vitePluginRscCss(rscPluginOptions, manager), { diff --git a/packages/plugin-rsc/src/plugins/import-environment.ts b/packages/plugin-rsc/src/plugins/import-environment.ts new file mode 100644 index 000000000..5cbfa71ed --- /dev/null +++ b/packages/plugin-rsc/src/plugins/import-environment.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert' +import fs from 'node:fs' +import path from 'node:path' +import MagicString from 'magic-string' +import { stripLiteral } from 'strip-literal' +import type { Plugin, ResolvedConfig } from 'vite' +import type { RscPluginManager } from '../plugin' +import { + createVirtualPlugin, + normalizeRelativePath, + normalizeRollupOpitonsInput, +} from './utils' +import { evalValue } from './vite-utils' + +export const ENV_IMPORTS_MANIFEST_NAME = '__vite_rsc_env_imports_manifest.js' + +const ENV_IMPORTS_MANIFEST_PLACEHOLDER = 'virtual:vite-rsc/env-imports-manifest' +const ENV_IMPORTS_ENTRY_FALLBACK = 'virtual:vite-rsc/env-imports-entry-fallback' + +export type EnvironmentImportMeta = { + resolvedId: string + targetEnv: string + sourceEnv: string + specifier: string +} + +// ensure at least one entry since otherwise rollup build fails +export function ensureEnvironmentImportsEntryFallback({ + environments, +}: ResolvedConfig): void { + for (const [name, config] of Object.entries(environments)) { + if (name === 'client') continue + const input = normalizeRollupOpitonsInput( + config.build?.rollupOptions?.input, + ) + if (Object.keys(input).length === 0) { + config.build = config.build || {} + config.build.rollupOptions = config.build.rollupOptions || {} + config.build.rollupOptions.input = { + __vite_rsc_env_imports_entry_fallback: ENV_IMPORTS_ENTRY_FALLBACK, + } + } + } +} + +export function vitePluginImportEnvironment( + manager: RscPluginManager, +): Plugin[] { + return [ + { + name: 'rsc:import-environment', + resolveId(source) { + // Use placeholder as external, renderChunk will replace with correct relative path + if (source === ENV_IMPORTS_MANIFEST_PLACEHOLDER) { + return { id: ENV_IMPORTS_MANIFEST_PLACEHOLDER, external: true } + } + }, + buildStart() { + // Emit discovered entries during build + if (this.environment.mode !== 'build') return + + // Collect unique entries targeting this environment (may be imported from multiple sources) + const emitted = new Set() + for (const byTargetEnv of Object.values( + manager.environmentImportMetaMap, + )) { + const imports = byTargetEnv[this.environment.name] + if (!imports) continue + for (const meta of Object.values(imports)) { + if (!emitted.has(meta.resolvedId)) { + emitted.add(meta.resolvedId) + this.emitFile({ + type: 'chunk', + id: meta.resolvedId, + }) + } + } + } + }, + transform: { + async handler(code, id) { + if (!code.includes('import.meta.viteRsc.import')) return + + const { server } = manager + const s = new MagicString(code) + + for (const match of stripLiteral(code).matchAll( + /import\.meta\.viteRsc\.import\s*(<[\s\S]*?>)?\s*\(([\s\S]*?)\)/dg, + )) { + // match[2] is the arguments (after optional type parameter) + const [argStart, argEnd] = match.indices![2]! + const argCode = code.slice(argStart, argEnd).trim() + + // Parse: ('./entry.ssr', { environment: 'ssr' }) + const [specifier, options]: [string, { environment: string }] = + evalValue(`[${argCode}]`) + const environmentName = options.environment + + // Resolve specifier relative to importer + let resolvedId: string + if (this.environment.mode === 'dev') { + const targetEnv = server.environments[environmentName] + assert( + targetEnv, + `[vite-rsc] unknown environment '${environmentName}'`, + ) + const resolved = await targetEnv.pluginContainer.resolveId( + specifier, + id, + ) + assert( + resolved, + `[vite-rsc] failed to resolve '${specifier}' in environment '${environmentName}'`, + ) + resolvedId = resolved.id + } else { + // Build mode: resolve in target environment config + const targetEnvConfig = + manager.config.environments[environmentName] + assert( + targetEnvConfig, + `[vite-rsc] unknown environment '${environmentName}'`, + ) + // Use this environment's resolver for now + const resolved = await this.resolve(specifier, id) + assert( + resolved, + `[vite-rsc] failed to resolve '${specifier}' in environment '${environmentName}'`, + ) + resolvedId = resolved.id + } + + // Track discovered entry, keyed by [sourceEnv][targetEnv][resolvedId] + const sourceEnv = this.environment.name + const targetEnv = environmentName + manager.environmentImportMetaMap[sourceEnv] ??= {} + manager.environmentImportMetaMap[sourceEnv]![targetEnv] ??= {} + manager.environmentImportMetaMap[sourceEnv]![targetEnv]![ + resolvedId + ] = { + resolvedId, + targetEnv, + sourceEnv, + specifier, + } + + let replacement: string + if (this.environment.mode === 'dev') { + replacement = `globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__(${JSON.stringify(environmentName)}, ${JSON.stringify(resolvedId)})` + } else { + // Build: emit manifest lookup with static import + // The manifest is generated in buildApp after all builds complete + // Use placeholder that renderChunk will replace with correct relative path + // Use relative ID for stable builds across different machines + const relativeId = manager.toRelativeId(resolvedId) + replacement = `(await import(${JSON.stringify(ENV_IMPORTS_MANIFEST_PLACEHOLDER)})).default[${JSON.stringify(relativeId)}]()` + } + + const [start, end] = match.indices![0]! + s.overwrite(start, end, replacement) + } + + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + } + }, + }, + + renderChunk(code, chunk) { + if (code.includes(ENV_IMPORTS_MANIFEST_PLACEHOLDER)) { + const replacement = normalizeRelativePath( + path.relative( + path.join(chunk.fileName, '..'), + ENV_IMPORTS_MANIFEST_NAME, + ), + ) + code = code.replaceAll( + ENV_IMPORTS_MANIFEST_PLACEHOLDER, + () => replacement, + ) + return { code } + } + return + }, + }, + createVirtualPlugin( + ENV_IMPORTS_ENTRY_FALLBACK.slice('virtual:'.length), + () => { + // TODO: how to avoid warning during scan build? + // > Generated an empty chunk: "__vite_rsc_env_imports_entry_fallback". + return `export default "__vite_rsc_env_imports_entry_fallback";` + }, + ), + ] +} + +export function writeEnvironmentImportsManifest( + manager: RscPluginManager, +): void { + if (Object.keys(manager.environmentImportMetaMap).length === 0) { + return + } + + // Write manifest to each source environment's output + for (const [sourceEnv, byTargetEnv] of Object.entries( + manager.environmentImportMetaMap, + )) { + const sourceOutDir = manager.config.environments[sourceEnv]!.build.outDir + const manifestPath = path.join(sourceOutDir, ENV_IMPORTS_MANIFEST_NAME) + + let code = 'export default {\n' + for (const [_targetEnv, imports] of Object.entries(byTargetEnv)) { + // Lookup fileName from bundle + for (const [resolvedId, meta] of Object.entries(imports)) { + const bundle = manager.bundles[meta.targetEnv] + const chunk = Object.values(bundle ?? {}).find( + (c) => + c.type === 'chunk' && c.isEntry && c.facadeModuleId === resolvedId, + ) + if (!chunk) { + throw new Error( + `[vite-rsc] missing output for environment import: ${resolvedId}`, + ) + } + const targetOutDir = + manager.config.environments[meta.targetEnv]!.build.outDir + const relativePath = normalizeRelativePath( + path.relative(sourceOutDir, path.join(targetOutDir, chunk.fileName)), + ) + // Use relative ID for stable builds across different machines + const relativeId = manager.toRelativeId(resolvedId) + code += ` ${JSON.stringify(relativeId)}: () => import(${JSON.stringify(relativePath)}),\n` + } + } + code += '}\n' + + fs.writeFileSync(manifestPath, code) + } +} diff --git a/packages/plugin-rsc/src/plugins/utils.ts b/packages/plugin-rsc/src/plugins/utils.ts index 3a7d8283e..803139c5c 100644 --- a/packages/plugin-rsc/src/plugins/utils.ts +++ b/packages/plugin-rsc/src/plugins/utils.ts @@ -100,7 +100,7 @@ export function getFallbackRollupEntry( // normalize to object form // https://rollupjs.org/configuration-options/#input // https://rollupjs.org/configuration-options/#output-entryfilenames -function normalizeRollupOpitonsInput( +export function normalizeRollupOpitonsInput( input: Rollup.InputOptions['input'] = {}, ): Record { if (typeof input === 'string') { diff --git a/packages/plugin-rsc/types/index.d.ts b/packages/plugin-rsc/types/index.d.ts index 8d617dbbe..5eafb4ee1 100644 --- a/packages/plugin-rsc/types/index.d.ts +++ b/packages/plugin-rsc/types/index.d.ts @@ -4,6 +4,29 @@ declare global { loadCss: (importer?: string) => import('react').ReactNode loadModule: (environmentName: string, entryName?: string) => Promise loadBootstrapScriptContent: (entryName: string) => Promise + /** + * Import a module from another environment using a module specifier. + * + * A more ergonomic alternative to `loadModule` that takes a relative path + * instead of an entry name, so the specifier matches what you'd use in + * `typeof import(...)` type annotations. + * + * @example + * ```ts + * const ssr = await import.meta.viteRsc.import( + * './entry.ssr', + * { environment: 'ssr' } + * ); + * ``` + * + * @param specifier - Relative path to the module (e.g., './entry.ssr') + * @param options - Options object with `environment` specifying the target environment + * @returns Promise resolving to the module exports + */ + import: ( + specifier: string, + options: { environment: string }, + ) => Promise } }