|
| 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. |
0 commit comments