Skip to content

Commit a4572ab

Browse files
authored
fix(rsc): copy only imported css and assets from rsc environemnt to client environment (#1112)
1 parent be3b170 commit a4572ab

File tree

3 files changed

+213
-21
lines changed

3 files changed

+213
-21
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# Issue #1109 - Safer RSC server-asset copying
2+
3+
## Problem statement
4+
5+
`@vitejs/plugin-rsc` currently copies almost every emitted asset from the `rsc` build into the `client` build (`packages/plugin-rsc/src/plugin.ts`, around `generateBundle`).
6+
7+
That behavior is convenient, but unsafe as a default:
8+
9+
- it can expose server-only assets produced by framework/user plugins via `emitFile({ type: "asset" })`
10+
- it relies on a coarse heuristic (`copyServerAssetsToClient ?? (() => true)`)
11+
- it already needs special-case exclusions (`.vite/manifest.json`), which is a smell
12+
13+
## Implementation status (final)
14+
15+
Implemented on 2026-02-16 with follow-up refinements.
16+
17+
- `generateBundle` in `packages/plugin-rsc/src/plugin.ts` now copies only assets referenced by RSC chunk metadata (`importedCss` + `importedAssets`).
18+
- The public-asset selection is inlined (no helper wrapper): `Object.values(rscBundle).flatMap(...)` -> `new Set(...)` -> emit by filename with `assert(asset?.type === 'asset')`.
19+
- `copyServerAssetsToClient` remains in `RscPluginOptions` but is deprecated and treated as a no-op.
20+
- Redundant manifest exclusion logic was removed; copied assets are strictly selected from chunk metadata.
21+
- `packages/plugin-rsc/examples/basic/vite.config.ts` no longer relies on `copyServerAssetsToClient` for `__server_secret.txt`.
22+
- `packages/plugin-rsc/README.md` now labels `copyServerAssetsToClient` as deprecated no-op.
23+
24+
## What we should copy by default
25+
26+
Copy only assets that are clearly public-by-construction from RSC code:
27+
28+
1. side-effect CSS used by server components
29+
2. `?url` (and similar Vite asset URL) imports that flow into rendered markup
30+
3. transitive assets referenced by those assets (via chunk metadata, e.g. fonts/images from copied CSS)
31+
32+
Do **not** copy arbitrary emitted assets that are not referenced from RSC chunk metadata.
33+
34+
## Concrete implementation idea
35+
36+
### 1) Add an explicit "public server assets" collector
37+
38+
Introduce a helper in `packages/plugin-rsc/src/plugin.ts` (next to `collectAssetDeps*`) that traverses the `rsc` output bundle and returns a `Set<string>` of asset file names to copy.
39+
40+
Suggested logic:
41+
42+
- Walk all output chunks in the RSC bundle.
43+
- Collect roots from `chunk.viteMetadata`:
44+
- `importedCss`
45+
- `importedAssets`
46+
- (original idea) recursively walk collected assets via `asset.viteMetadata`
47+
- (updated implementation) rely only on chunk metadata (`chunk.viteMetadata.importedCss/importedAssets`) for cross-version consistency
48+
49+
This follows Vite's own metadata contract (verified in `~/code/others/vite`):
50+
51+
- `packages/vite/types/metadata.d.ts` defines `importedCss` and `importedAssets` on chunks/assets
52+
- Vite plugins populate these sets in asset/css transforms
53+
54+
### 2) Change default copy behavior in `generateBundle`
55+
56+
Current:
57+
58+
- iterate all RSC assets and copy when `filterAssets(fileName)` passes
59+
60+
Implemented:
61+
62+
- compute `assets` from chunk metadata only:
63+
- `Object.values(rscBundle).flatMap(output => output.type === 'chunk' ? [...importedCss, ...importedAssets] : [])`
64+
- `new Set(...)` for dedupe
65+
- emit only those filenames from `rscBundle` with `assert(asset?.type === 'asset')`
66+
- no manifest special-case branch needed
67+
68+
### 3) `copyServerAssetsToClient` status
69+
70+
- kept only for compatibility at type level
71+
- marked deprecated
72+
- runtime behavior is no-op (does not affect copy selection)
73+
74+
## Why this addresses #1109
75+
76+
- server-only `emitFile` assets are no longer copied unless they are referenced by public RSC asset metadata
77+
- intended public assets (CSS and `?url`) continue to work
78+
- behavior aligns with issue intent: secure default + explicit opt-in for non-standard cases
79+
80+
## Expected compatibility impact
81+
82+
- Potentially breaking for setups that relied on accidental copying of unreferenced server assets.
83+
- This is acceptable for security-hardening behavior, but should be called out in changelog.
84+
- Existing `copyServerAssetsToClient` configurations no longer affect behavior.
85+
86+
## Test plan
87+
88+
Add/adjust e2e checks in `packages/plugin-rsc/examples/basic` tests:
89+
90+
1. **Security baseline**: emitted `__server_secret.txt` from RSC is present in RSC output and absent in client output by default.
91+
2. **RSC CSS**: CSS imported in server component is available in client output and links resolve.
92+
3. **RSC `?url`**: `import x from './foo.css?url'` (or image/font) from server component resolves to an existing client asset.
93+
4. **Transitive CSS assets**: font/image referenced from copied CSS is also copied.
94+
5. **Deprecated option behavior**: `copyServerAssetsToClient` is accepted but has no effect.
95+
96+
## Optional follow-up (if we want Astro-like ergonomics)
97+
98+
Add a dedicated API for intentional server->client asset emission (conceptually similar to Astro's `emitClientAsset`).
99+
100+
This is not required for the initial fix, but could improve composability for framework authors that legitimately need to publish custom artifacts.
101+
102+
## Follow-up design: deprecate `copyServerAssetsToClient`, add experimental explicit API
103+
104+
### Prior art from Astro (`~/code/others/astro`)
105+
106+
Astro recently introduced `emitClientAsset` and uses it as an explicit opt-in path for SSR->client asset movement:
107+
108+
- `packages/astro/src/assets/utils/assets.ts`
109+
- `emitClientAsset(pluginContext, options)` calls `emitFile(options)` and tracks the returned handle.
110+
- tracking is per-environment via `WeakMap<Environment, Set<string>>`.
111+
- `packages/astro/src/core/build/vite-plugin-ssr-assets.ts`
112+
- `buildStart`: reset handle tracking for current env.
113+
- `generateBundle`: resolve tracked handles to output filenames via `this.getFileName(handle)`.
114+
- `writeBundle`: merge in manifest-derived CSS/assets as always-public assets.
115+
- `packages/integrations/markdoc/src/content-entry-type.ts`
116+
- integration authors call `emitClientAsset(...)` only during build mode.
117+
118+
This pattern maps well to RSC needs: explicit emit intent + deferred handle->filename resolution in bundling phase.
119+
120+
### Proposed API shape for `@vitejs/plugin-rsc`
121+
122+
Introduce an experimental helper export:
123+
124+
```ts
125+
// e.g. @vitejs/plugin-rsc/assets (or root export behind `experimental` namespace)
126+
export function emitClientAsset(
127+
pluginContext: Rollup.PluginContext,
128+
options: Parameters<Rollup.PluginContext['emitFile']>[0],
129+
): string
130+
```
131+
132+
Recommended runtime constraints:
133+
134+
- allow only `options.type === "asset"` for v1 (throw otherwise)
135+
- require build mode (`!pluginContext.meta.watchMode`) for now
136+
- only collect when `pluginContext.environment.name === "rsc"`
137+
138+
### Internal implementation idea in plugin-rsc
139+
140+
1. Track explicit handles on the manager
141+
142+
- Add `clientAssetHandlesByEnv: WeakMap<vite.Environment, Set<string>>` (or `Map<string, Set<string>>` keyed by env name) in shared state.
143+
- Expose internal helpers:
144+
- `trackClientAssetHandle(environment, handle)`
145+
- `resetClientAssetHandles(environment)`
146+
- `resolveClientAssetFileNames(environment, pluginContext)`
147+
148+
2. Lifecycle integration
149+
150+
- `buildStart` (for each build env): reset handle set.
151+
- `generateBundle` (for each env): resolve tracked handles with `this.getFileName(handle)` and store resolved filenames in manager, e.g. `explicitClientAssetsByEnv[envName]`.
152+
153+
3. Merge with current default copy policy
154+
155+
- In client `generateBundle`, compute candidate copy set as:
156+
- `collectPublicServerAssets(rscBundle)` (current safe default)
157+
- union `explicitClientAssetsByEnv.rsc` (new explicit opt-in assets)
158+
- copy only this union by default.
159+
160+
This gives a secure default while still supporting framework-specific artifacts intentionally emitted from RSC.
161+
162+
### Deprecation plan for `copyServerAssetsToClient`
163+
164+
Phase 1 (next minor):
165+
166+
- keep option working, but mark `@deprecated` in type docs and README.
167+
- warn once at runtime when option is used:
168+
- "`copyServerAssetsToClient` is deprecated; prefer experimental `emitClientAsset` for explicit opt-in assets."
169+
- behavior unchanged for compatibility.
170+
171+
Phase 2 (future major):
172+
173+
- remove option and rely on:
174+
- safe metadata-based default
175+
- explicit `emitClientAsset` for non-standard assets
176+
177+
### Migration story
178+
179+
- Before: framework/plugin used `copyServerAssetsToClient` to allow/deny by filename pattern.
180+
- After: framework/plugin emits only intended public artifacts via `emitClientAsset(this, { type: "asset", ... })` from `rsc` environment.
181+
- No change needed for normal CSS and `?url` imports; those continue to work via metadata collector.
182+
183+
### Additional tests for this follow-up
184+
185+
1. `emitClientAsset` from `rsc` causes asset to appear in client output.
186+
2. plain `emitFile({ type: "asset" })` from `rsc` is not copied by default.
187+
3. `copyServerAssetsToClient` emits deprecation warning (once).
188+
4. `emitClientAsset` rejects non-asset emission in v1.
189+
190+
## Note on Vite metadata scope
191+
192+
Final implementation intentionally relies only on `chunk.viteMetadata.importedCss/importedAssets`.
193+
This avoids depending on asset-level metadata contracts across Vite versions and backends.

packages/plugin-rsc/examples/basic/vite.config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ export default defineConfig({
2828
ssr: './src/framework/entry.ssr.tsx',
2929
rsc: './src/framework/entry.rsc.tsx',
3030
},
31-
copyServerAssetsToClient: (fileName) =>
32-
fileName !== '__server_secret.txt',
3331
clientChunks(meta) {
3432
if (process.env.TEST_CUSTOM_CLIENT_CHUNKS) {
3533
if (meta.id.includes('/src/routes/chunk/')) {

packages/plugin-rsc/src/plugin.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,7 @@ export type RscPluginOptions = {
188188
rscCssTransform?: false | { filter?: (id: string) => boolean }
189189

190190
/**
191-
* This option allows customizing how client build copies assets from server build.
192-
* By default, all assets are copied, but frameworks can establish server asset convention
193-
* to tighten security using this option.
191+
* @deprecated This option is a no-op and will be removed in a future major.
194192
*/
195193
copyServerAssetsToClient?: (fileName: string) => boolean
196194

@@ -1068,22 +1066,25 @@ export function createRpcClient(params) {
10681066
manager.bundles[this.environment.name] = bundle
10691067

10701068
if (this.environment.name === 'client') {
1071-
const filterAssets =
1072-
rscPluginOptions.copyServerAssetsToClient ?? (() => true)
1073-
const rscBuildOptions = manager.config.environments.rsc!.build
1074-
const rscViteManifest =
1075-
typeof rscBuildOptions.manifest === 'string'
1076-
? rscBuildOptions.manifest
1077-
: rscBuildOptions.manifest && '.vite/manifest.json'
1078-
for (const asset of Object.values(manager.bundles['rsc']!)) {
1079-
if (asset.fileName === rscViteManifest) continue
1080-
if (asset.type === 'asset' && filterAssets(asset.fileName)) {
1081-
this.emitFile({
1082-
type: 'asset',
1083-
fileName: asset.fileName,
1084-
source: asset.source,
1085-
})
1086-
}
1069+
const rscBundle = manager.bundles['rsc']!
1070+
const assets = new Set(
1071+
Object.values(rscBundle).flatMap((output) =>
1072+
output.type === 'chunk'
1073+
? [
1074+
...(output.viteMetadata?.importedCss ?? []),
1075+
...(output.viteMetadata?.importedAssets ?? []),
1076+
]
1077+
: [],
1078+
),
1079+
)
1080+
for (const fileName of assets) {
1081+
const asset = rscBundle[fileName]
1082+
assert(asset?.type === 'asset')
1083+
this.emitFile({
1084+
type: 'asset',
1085+
fileName: asset.fileName,
1086+
source: asset.source,
1087+
})
10871088
}
10881089

10891090
const serverResources: Record<string, AssetDeps> = {}

0 commit comments

Comments
 (0)