Skip to content

Commit e663662

Browse files
hi-ogawaclaude
andcommitted
feat(rsc): implement manifest-based viteRsc.import for custom entryFileNames
Replace hardcoded output filenames with a manifest approach that supports custom entryFileNames config. The manifest uses static import functions for proper bundler analysis. Changes: - Transform emits manifest lookup instead of markers - resolveId marks manifest as external during build - generateBundle tracks resolvedId → outputFileName mapping - writeEnvironmentImportsManifest generates manifest after all builds - Split entry injection for bidirectional support (RSC↔SSR) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2fe7f44 commit e663662

File tree

3 files changed

+107
-68
lines changed

3 files changed

+107
-68
lines changed

packages/plugin-rsc/docs/notes/2026-01-16-vitersc-import.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,7 @@ Transform emits manifest lookup:
281281
```ts
282282
// Original:
283283
await import.meta.viteRsc
284-
.import('./entry.ssr', { environment: 'ssr' })
285-
(
284+
.import('./entry.ssr', { environment: 'ssr' })(
286285
// Build transform to:
287286
await import('./__vite_rsc_env_imports_manifest.js'),
288287
)
@@ -439,12 +438,12 @@ export default {
439438
2. [x] Clean up `import-environment.ts`: add imports, parameterize with manager
440439
3. [x] Implement transform to discover and track imports
441440
4. [x] Inject discovered entries into target environment's input
442-
5. [ ] **Fix**: Move entry injection to AFTER both scans (currently after RSC scan only)
443-
6. [ ] Update transform to emit manifest lookup (build mode)
444-
7. [ ] Remove renderChunk marker replacement
445-
8. [ ] Add `environmentImportOutputMap` to track resolvedId → outputFileName
446-
9. [ ] Add generateBundle hook to populate output map (in both RSC and SSR)
447-
10. [ ] Add `writeEnvironmentImportsManifest` in buildApp (per source environment)
448-
11. [ ] Test with basic example (RSC → SSR)
441+
5. [x] Split entry injection: after RSC scan (RSC → other), after SSR scan (SSR → other)
442+
6. [x] Update transform to emit manifest lookup (build mode)
443+
7. [x] Remove renderChunk marker replacement, add resolveId to mark manifest as external
444+
8. [x] Add `environmentImportOutputMap` to track resolvedId → outputFileName
445+
9. [x] Add generateBundle hook to populate output map (in both RSC and SSR)
446+
10. [x] Add `writeEnvironmentImportsManifest` in buildApp (per source environment)
447+
11. [x] Test with basic example (RSC → SSR) - all 38 starter tests pass
449448
12. [ ] Test bidirectional (SSR → RSC) if applicable
450449
13. [ ] Update documentation

packages/plugin-rsc/src/plugin.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import { crawlFrameworkPkgs } from 'vitefu'
2727
import vitePluginRscCore from './core/plugin'
2828
import { cjsModuleRunnerPlugin } from './plugins/cjs'
2929
import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url'
30-
import { vitePluginImportEnvironment } from './plugins/import-environment'
30+
import {
31+
ENV_IMPORTS_MANIFEST_NAME,
32+
vitePluginImportEnvironment,
33+
} from './plugins/import-environment'
3134
import {
3235
vitePluginResolvedIdProxy,
3336
withResolvedIdProxy,
@@ -131,6 +134,8 @@ class RscPluginManager {
131134
entryName: string
132135
}
133136
> = {}
137+
// Maps resolvedId to output fileName (populated in generateBundle)
138+
environmentImportOutputMap: Record<string, string> = {}
134139

135140
stabilize(): void {
136141
// sort for stable build
@@ -156,6 +161,47 @@ class RscPluginManager {
156161
fs.writeFileSync(manifestPath, assetsManifestCode)
157162
}
158163
}
164+
165+
writeEnvironmentImportsManifest(): void {
166+
if (Object.keys(this.environmentImportMetaMap).length === 0) {
167+
return
168+
}
169+
170+
// Group imports by source environment
171+
const bySourceEnv: Record<string, typeof this.environmentImportMetaMap> = {}
172+
for (const [resolvedId, meta] of Object.entries(
173+
this.environmentImportMetaMap,
174+
)) {
175+
bySourceEnv[meta.sourceEnv] ??= {}
176+
bySourceEnv[meta.sourceEnv]![resolvedId] = meta
177+
}
178+
179+
// Write manifest to each source environment's output
180+
for (const [sourceEnv, imports] of Object.entries(bySourceEnv)) {
181+
const sourceOutDir = this.config.environments[sourceEnv]!.build.outDir
182+
const manifestPath = path.join(sourceOutDir, ENV_IMPORTS_MANIFEST_NAME)
183+
184+
let code = 'export default {\n'
185+
for (const [resolvedId, meta] of Object.entries(imports)) {
186+
const outputFileName = this.environmentImportOutputMap[resolvedId]
187+
if (!outputFileName) {
188+
console.warn(
189+
`[vite-rsc] missing output for environment import: ${resolvedId}`,
190+
)
191+
continue
192+
}
193+
const targetOutDir =
194+
this.config.environments[meta.targetEnv]!.build.outDir
195+
const relativePath = normalizeRelativePath(
196+
path.relative(sourceOutDir, path.join(targetOutDir, outputFileName)),
197+
)
198+
code += ` ${JSON.stringify(resolvedId)}: () => import(${JSON.stringify(relativePath)}),\n`
199+
}
200+
code += '}\n'
201+
202+
fs.writeFileSync(manifestPath, code)
203+
}
204+
}
159205
}
160206

161207
export type RscPluginOptions = {
@@ -381,6 +427,7 @@ export default function vitePluginRsc(
381427
logStep('[4/4] build client environment...')
382428
await builder.build(builder.environments.client!)
383429
manager.writeAssetsManifest(['rsc'])
430+
manager.writeEnvironmentImportsManifest()
384431
return
385432
}
386433

@@ -412,8 +459,9 @@ export default function vitePluginRsc(
412459
logStep('[1/5] analyze client references...')
413460
await builder.build(builder.environments.rsc!)
414461

415-
// Inject discovered environment imports into target environment's input
416-
// This must happen after RSC scan discovers them
462+
// TODO: let's configuere dummy input e.g. __vite_rsc_xxx: "virtual:..."
463+
// Inject RSC → other environment imports discovered during RSC scan
464+
// This must happen before SSR scan so SSR has input
417465
for (const meta of Object.values(manager.environmentImportMetaMap)) {
418466
const targetEnv = builder.environments[meta.targetEnv]
419467
if (targetEnv) {
@@ -425,14 +473,32 @@ export default function vitePluginRsc(
425473
}
426474
}
427475

428-
if (
429-
hasSsrInput ||
430-
Object.keys(manager.environmentImportMetaMap).length > 0
431-
) {
476+
// Check if we need SSR scan (has input or discovered imports targeting SSR)
477+
// TODO: no need of optimization yet. just leave it as future follow TODO.
478+
const ssrInputAfterRsc = builder.environments.ssr!.config.build
479+
.rollupOptions.input as Record<string, string> | undefined
480+
const hasSsrInputAfterRsc =
481+
ssrInputAfterRsc && Object.keys(ssrInputAfterRsc).length > 0
482+
483+
if (hasSsrInput || hasSsrInputAfterRsc) {
432484
builder.environments.ssr!.config.build.write = false
433485
logStep('[2/5] analyze server references...')
434486
await builder.build(builder.environments.ssr!)
487+
488+
// Inject SSR → other environment imports discovered during SSR scan
489+
// (for bidirectional support)
490+
for (const meta of Object.values(manager.environmentImportMetaMap)) {
491+
const targetEnv = builder.environments[meta.targetEnv]
492+
if (targetEnv) {
493+
const input = (targetEnv.config.build.rollupOptions.input ??=
494+
{}) as Record<string, string>
495+
if (!(meta.entryName in input)) {
496+
input[meta.entryName] = meta.resolvedId
497+
}
498+
}
499+
}
435500
}
501+
436502
manager.isScanBuild = false
437503
builder.environments.rsc!.config.build.write = true
438504
builder.environments.ssr!.config.build.write = true
@@ -465,6 +531,7 @@ export default function vitePluginRsc(
465531
}
466532

467533
manager.writeAssetsManifest(['ssr', 'rsc'])
534+
manager.writeEnvironmentImportsManifest()
468535
}
469536

470537
let hasReactServerDomWebpack = false

packages/plugin-rsc/src/plugins/import-environment.ts

Lines changed: 25 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import path from 'node:path'
33
import MagicString from 'magic-string'
44
import { stripLiteral } from 'strip-literal'
55
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
6-
import { normalizeRelativePath } from './utils'
76
import { evalValue } from './vite-utils'
87

8+
export const ENV_IMPORTS_MANIFEST_NAME = '__vite_rsc_env_imports_manifest.js'
9+
910
export type EnvironmentImportMeta = {
1011
resolvedId: string
1112
targetEnv: string
@@ -18,12 +19,23 @@ interface PluginManager {
1819
server: ViteDevServer
1920
config: ResolvedConfig
2021
environmentImportMetaMap: Record<string, EnvironmentImportMeta>
22+
environmentImportOutputMap: Record<string, string>
2123
}
2224

2325
export function vitePluginImportEnvironment(manager: PluginManager): Plugin[] {
2426
return [
2527
{
2628
name: 'rsc:import-environment',
29+
resolveId(source) {
30+
// Mark manifest imports as external during build
31+
// The actual file is generated in buildApp after all builds complete
32+
if (
33+
this.environment.mode === 'build' &&
34+
source.endsWith(ENV_IMPORTS_MANIFEST_NAME)
35+
) {
36+
return { id: './' + ENV_IMPORTS_MANIFEST_NAME, external: true }
37+
}
38+
},
2739
transform: {
2840
async handler(code, id) {
2941
if (!code.includes('import.meta.viteRsc.import')) return
@@ -93,16 +105,9 @@ export function vitePluginImportEnvironment(manager: PluginManager): Plugin[] {
93105
if (this.environment.mode === 'dev') {
94106
replacement = `globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__(${JSON.stringify(environmentName)}, ${JSON.stringify(resolvedId)})`
95107
} else {
96-
// Build: emit marker that will be resolved in renderChunk
97-
replacement = JSON.stringify(
98-
`__vite_rsc_import_env_start__:` +
99-
JSON.stringify({
100-
fromEnv: this.environment.name,
101-
toEnv: environmentName,
102-
entryName,
103-
}) +
104-
`:__vite_rsc_import_env_end__`,
105-
)
108+
// Build: emit manifest lookup with static import
109+
// The manifest is generated in buildApp after all builds complete
110+
replacement = `(await import(${JSON.stringify('./' + ENV_IMPORTS_MANIFEST_NAME)})).default[${JSON.stringify(resolvedId)}]()`
106111
}
107112

108113
const [start, end] = match.indices![0]!
@@ -118,47 +123,15 @@ export function vitePluginImportEnvironment(manager: PluginManager): Plugin[] {
118123
},
119124
},
120125

121-
renderChunk(code, chunk) {
122-
if (!code.includes('__vite_rsc_import_env')) return
123-
124-
const { config } = manager
125-
const s = new MagicString(code)
126-
127-
for (const match of code.matchAll(
128-
/[`'"]__vite_rsc_import_env_start__:([\s\S]*?):__vite_rsc_import_env_end__[`'"]/dg,
129-
)) {
130-
const markerString = evalValue(match[0])
131-
const { fromEnv, toEnv, entryName } = JSON.parse(
132-
markerString.slice(
133-
'__vite_rsc_import_env_start__:'.length,
134-
-':__vite_rsc_import_env_end__'.length,
135-
),
136-
)
137-
138-
const targetFileName = `${entryName}.js`
139-
const importPath = normalizeRelativePath(
140-
path.relative(
141-
path.join(
142-
config.environments[fromEnv!]!.build.outDir,
143-
chunk.fileName,
144-
'..',
145-
),
146-
path.join(
147-
config.environments[toEnv!]!.build.outDir,
148-
targetFileName,
149-
),
150-
),
151-
)
152-
153-
const replacement = `(import(${JSON.stringify(importPath)}))`
154-
const [start, end] = match.indices![0]!
155-
s.overwrite(start, end, replacement)
156-
}
157-
158-
if (s.hasChanged()) {
159-
return {
160-
code: s.toString(),
161-
map: s.generateMap({ hires: 'boundary' }),
126+
generateBundle(_options, bundle) {
127+
// Track output filenames for discovered environment imports
128+
// This runs in both RSC and SSR builds to capture all outputs
129+
for (const [fileName, chunk] of Object.entries(bundle)) {
130+
if (chunk.type === 'chunk' && chunk.isEntry && chunk.facadeModuleId) {
131+
const resolvedId = chunk.facadeModuleId
132+
if (resolvedId in manager.environmentImportMetaMap) {
133+
manager.environmentImportOutputMap[resolvedId] = fileName
134+
}
162135
}
163136
}
164137
},

0 commit comments

Comments
 (0)