Skip to content

Commit 51f8e91

Browse files
hi-ogawaclaude
andcommitted
feat(rsc): implement import.meta.viteRsc.import transform
- Add import-environment plugin with transform/renderChunk hooks - Dev mode: rewrite to globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ - Build mode: emit marker, resolve in renderChunk to relative import - Auto-discover entries and inject into target environment's input - Handle empty SSR input case in buildApp orchestration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 82d0cb1 commit 51f8e91

File tree

2 files changed

+215
-6
lines changed

2 files changed

+215
-6
lines changed

packages/plugin-rsc/src/plugin.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ 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'
3031
import {
3132
vitePluginResolvedIdProxy,
3233
withResolvedIdProxy,
@@ -120,6 +121,16 @@ class RscPluginManager {
120121
{}
121122
serverReferenceMetaMap: Record<string, ServerRerferenceMeta> = {}
122123
serverResourcesMetaMap: Record<string, { key: string }> = {}
124+
environmentImportMetaMap: Record<
125+
string,
126+
{
127+
resolvedId: string
128+
targetEnv: string
129+
sourceEnv: string
130+
specifier: string
131+
entryName: string
132+
}
133+
> = {}
123134

124135
stabilize(): void {
125136
// sort for stable build
@@ -388,16 +399,44 @@ export default function vitePluginRsc(
388399
)
389400

390401
// rsc -> ssr -> rsc -> client -> ssr
402+
// Check if SSR has entries configured - if not, we'll inject after RSC scan
403+
const ssrInput = builder.environments.ssr!.config.build.rollupOptions
404+
.input as Record<string, string> | undefined
405+
const hasSsrInput = ssrInput && Object.keys(ssrInput).length > 0
406+
391407
manager.isScanBuild = true
392408
builder.environments.rsc!.config.build.write = false
393-
builder.environments.ssr!.config.build.write = false
409+
if (hasSsrInput) {
410+
builder.environments.ssr!.config.build.write = false
411+
}
394412
logStep('[1/5] analyze client references...')
395413
await builder.build(builder.environments.rsc!)
396-
logStep('[2/5] analyze server references...')
397-
await builder.build(builder.environments.ssr!)
414+
415+
// Inject discovered environment imports into target environment's input
416+
// This must happen after RSC scan discovers them
417+
for (const meta of Object.values(manager.environmentImportMetaMap)) {
418+
const targetEnv = builder.environments[meta.targetEnv]
419+
if (targetEnv) {
420+
const input = (targetEnv.config.build.rollupOptions.input ??=
421+
{}) as Record<string, string>
422+
if (!(meta.entryName in input)) {
423+
input[meta.entryName] = meta.resolvedId
424+
}
425+
}
426+
}
427+
428+
if (
429+
hasSsrInput ||
430+
Object.keys(manager.environmentImportMetaMap).length > 0
431+
) {
432+
builder.environments.ssr!.config.build.write = false
433+
logStep('[2/5] analyze server references...')
434+
await builder.build(builder.environments.ssr!)
435+
}
398436
manager.isScanBuild = false
399437
builder.environments.rsc!.config.build.write = true
400438
builder.environments.ssr!.config.build.write = true
439+
401440
logStep('[3/5] build rsc environment...')
402441
await builder.build(builder.environments.rsc!)
403442

@@ -1212,6 +1251,7 @@ import.meta.hot.on("rsc:update", () => {
12121251
},
12131252
),
12141253
...vitePluginRscMinimal(rscPluginOptions, manager),
1254+
...vitePluginImportEnvironment(manager),
12151255
...vitePluginFindSourceMapURL(),
12161256
...vitePluginRscCss(rscPluginOptions, manager),
12171257
{
Lines changed: 172 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,174 @@
1-
import type { Plugin } from 'vite'
1+
import assert from 'node:assert'
2+
import path from 'node:path'
3+
import MagicString from 'magic-string'
4+
import { stripLiteral } from 'strip-literal'
5+
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
6+
import { normalizeRelativePath } from './utils'
7+
import { evalValue } from './vite-utils'
28

3-
export function vitePluginImportEnvironment(): Plugin[] {
4-
return []
9+
export type EnvironmentImportMeta = {
10+
resolvedId: string
11+
targetEnv: string
12+
sourceEnv: string
13+
specifier: string
14+
entryName: string
15+
}
16+
17+
interface PluginManager {
18+
server: ViteDevServer
19+
config: ResolvedConfig
20+
environmentImportMetaMap: Record<string, EnvironmentImportMeta>
21+
}
22+
23+
export function vitePluginImportEnvironment(manager: PluginManager): Plugin[] {
24+
return [
25+
{
26+
name: 'rsc:import-environment',
27+
async transform(code, id) {
28+
if (!code.includes('import.meta.viteRsc.import')) return
29+
30+
const { server } = manager
31+
const s = new MagicString(code)
32+
33+
for (const match of stripLiteral(code).matchAll(
34+
/import\.meta\.viteRsc\.import\s*(<[\s\S]*?>)?\s*\(([\s\S]*?)\)/dg,
35+
)) {
36+
// match[2] is the arguments (after optional type parameter)
37+
const [argStart, argEnd] = match.indices![2]!
38+
const argCode = code.slice(argStart, argEnd).trim()
39+
40+
// Parse: ('./entry.ssr', { environment: 'ssr' })
41+
const [specifier, options]: [string, { environment: string }] =
42+
evalValue(`[${argCode}]`)
43+
const environmentName = options.environment
44+
45+
// Resolve specifier relative to importer
46+
let resolvedId: string
47+
if (this.environment.mode === 'dev') {
48+
const targetEnv = server.environments[environmentName]
49+
assert(
50+
targetEnv,
51+
`[vite-rsc] unknown environment '${environmentName}'`,
52+
)
53+
const resolved = await targetEnv.pluginContainer.resolveId(
54+
specifier,
55+
id,
56+
)
57+
assert(
58+
resolved,
59+
`[vite-rsc] failed to resolve '${specifier}' in environment '${environmentName}'`,
60+
)
61+
resolvedId = resolved.id
62+
} else {
63+
// Build mode: resolve in target environment config
64+
const targetEnvConfig = manager.config.environments[environmentName]
65+
assert(
66+
targetEnvConfig,
67+
`[vite-rsc] unknown environment '${environmentName}'`,
68+
)
69+
// Use this environment's resolver for now
70+
const resolved = await this.resolve(specifier, id)
71+
assert(
72+
resolved,
73+
`[vite-rsc] failed to resolve '${specifier}' in environment '${environmentName}'`,
74+
)
75+
resolvedId = resolved.id
76+
}
77+
78+
// Derive entry name from specifier (e.g., './entry.ssr.tsx' -> 'entry.ssr')
79+
const entryName = deriveEntryName(specifier)
80+
81+
// Track discovered entry
82+
manager.environmentImportMetaMap[resolvedId] = {
83+
resolvedId,
84+
targetEnv: environmentName,
85+
sourceEnv: this.environment.name,
86+
specifier,
87+
entryName,
88+
}
89+
90+
let replacement: string
91+
if (this.environment.mode === 'dev') {
92+
replacement = `globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__(${JSON.stringify(environmentName)}, ${JSON.stringify(resolvedId)})`
93+
} else {
94+
// Build: emit marker that will be resolved in renderChunk
95+
replacement = JSON.stringify(
96+
`__vite_rsc_import_env_start__:` +
97+
JSON.stringify({
98+
fromEnv: this.environment.name,
99+
toEnv: environmentName,
100+
entryName,
101+
}) +
102+
`:__vite_rsc_import_env_end__`,
103+
)
104+
}
105+
106+
const [start, end] = match.indices![0]!
107+
s.overwrite(start, end, replacement)
108+
}
109+
110+
if (s.hasChanged()) {
111+
return {
112+
code: s.toString(),
113+
map: s.generateMap({ hires: 'boundary' }),
114+
}
115+
}
116+
},
117+
118+
renderChunk(code, chunk) {
119+
if (!code.includes('__vite_rsc_import_env')) return
120+
121+
const { config } = manager
122+
const s = new MagicString(code)
123+
124+
for (const match of code.matchAll(
125+
/[`'"]__vite_rsc_import_env_start__:([\s\S]*?):__vite_rsc_import_env_end__[`'"]/dg,
126+
)) {
127+
const markerString = evalValue(match[0])
128+
const { fromEnv, toEnv, entryName } = JSON.parse(
129+
markerString.slice(
130+
'__vite_rsc_import_env_start__:'.length,
131+
-':__vite_rsc_import_env_end__'.length,
132+
),
133+
)
134+
135+
const targetFileName = `${entryName}.js`
136+
const importPath = normalizeRelativePath(
137+
path.relative(
138+
path.join(
139+
config.environments[fromEnv!]!.build.outDir,
140+
chunk.fileName,
141+
'..',
142+
),
143+
path.join(
144+
config.environments[toEnv!]!.build.outDir,
145+
targetFileName,
146+
),
147+
),
148+
)
149+
150+
const replacement = `(import(${JSON.stringify(importPath)}))`
151+
const [start, end] = match.indices![0]!
152+
s.overwrite(start, end, replacement)
153+
}
154+
155+
if (s.hasChanged()) {
156+
return {
157+
code: s.toString(),
158+
map: s.generateMap({ hires: 'boundary' }),
159+
}
160+
}
161+
},
162+
},
163+
]
164+
}
165+
166+
function deriveEntryName(specifier: string): string {
167+
// Remove leading ./ or ../
168+
let name = specifier.replace(/^\.\.?\//, '')
169+
// Remove extension
170+
name = name.replace(/\.[^.]+$/, '')
171+
// Get basename if it's a path
172+
name = path.basename(name)
173+
return name
5174
}

0 commit comments

Comments
 (0)