Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export async function renderHTML(
return React.use(payload).root
}

const asset = await import.meta.viteRsc.importAsset('./entry.browser.tsx', {
entry: true,
})
console.log('[importAsset]', asset.url)

// render html (traditional SSR)
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
Expand Down
33 changes: 33 additions & 0 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import { crawlFrameworkPkgs } from 'vitefu'
import vitePluginRscCore from './core/plugin'
import { cjsModuleRunnerPlugin } from './plugins/cjs'
import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url'
import {
vitePluginImportAsset,
type AssetImportMeta,
} from './plugins/import-asset'
import {
ensureEnvironmentImportsEntryFallback,
vitePluginImportEnvironment,
Expand Down Expand Up @@ -136,6 +140,13 @@ class RscPluginManager {
>
>
> = {}
assetImportMetaMap: Record<
string, // sourceEnv
Record<
string, // resolvedId
AssetImportMeta
>
> = {}

stabilize(): void {
// sort for stable build
Expand Down Expand Up @@ -1089,11 +1100,30 @@ export function createRpcClient(params) {
`"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`,
)
}
// Compute importAssets from assetImportMetaMap
const importAssets: Record<string, { url: string | RuntimeAsset }> =
{}
for (const metas of Object.values(manager.assetImportMetaMap)) {
for (const resolvedId of Object.keys(metas)) {
const chunk = Object.values(bundle).find(
(c) => c.type === 'chunk' && c.facadeModuleId === resolvedId,
)
if (chunk) {
const relativeId = manager.toRelativeId(resolvedId)
importAssets[relativeId] = {
url: assetsURL(chunk.fileName, manager),
}
}
}
}

manager.buildAssetsManifest = {
bootstrapScriptContent,
clientReferenceDeps,
serverResources,
cssLinkPrecedence: rscPluginOptions.cssLinkPrecedence,
importAssets:
Object.keys(importAssets).length > 0 ? importAssets : undefined,
}
}
},
Expand Down Expand Up @@ -1220,6 +1250,7 @@ import.meta.hot.on("rsc:update", () => {
),
...vitePluginRscMinimal(rscPluginOptions, manager),
...vitePluginImportEnvironment(manager),
...vitePluginImportAsset(manager),
...vitePluginFindSourceMapURL(),
...vitePluginRscCss(rscPluginOptions, manager),
{
Expand Down Expand Up @@ -1990,6 +2021,7 @@ export type AssetsManifest = {
clientReferenceDeps: Record<string, AssetDeps>
serverResources?: Record<string, Pick<AssetDeps, 'css'>>
cssLinkPrecedence?: boolean
importAssets?: Record<string, { url: string | RuntimeAsset }>
}

export type AssetDeps = {
Expand All @@ -2002,6 +2034,7 @@ export type ResolvedAssetsManifest = {
clientReferenceDeps: Record<string, ResolvedAssetDeps>
serverResources?: Record<string, Pick<ResolvedAssetDeps, 'css'>>
cssLinkPrecedence?: boolean
importAssets?: Record<string, { url: string }>
}

export type ResolvedAssetDeps = {
Expand Down
180 changes: 180 additions & 0 deletions packages/plugin-rsc/src/plugins/import-asset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import assert from 'node:assert'
import path from 'node:path'
import MagicString from 'magic-string'
import { stripLiteral } from 'strip-literal'
import { normalizePath, type Plugin } from 'vite'
import type { RscPluginManager } from '../plugin'
import { evalValue } from './vite-utils'

// Virtual module prefix for entry asset wrappers in dev mode
const ASSET_ENTRY_VIRTUAL_PREFIX = 'virtual:vite-rsc/asset-entry/'

export type AssetImportMeta = {
resolvedId: string
sourceEnv: string
specifier: string
isEntry: boolean
}

export function vitePluginImportAsset(manager: RscPluginManager): Plugin[] {
return [
{
name: 'rsc:import-asset',
resolveId(source) {
// Handle virtual asset entry modules
if (source.startsWith(ASSET_ENTRY_VIRTUAL_PREFIX)) {
return '\0' + source
}
},
async load(id) {
// Handle virtual asset entry modules in dev mode
if (id.startsWith('\0' + ASSET_ENTRY_VIRTUAL_PREFIX)) {
assert(this.environment.mode === 'dev')
const resolvedId = id.slice(
('\0' + ASSET_ENTRY_VIRTUAL_PREFIX).length,
)

let code = ''
// Enable HMR only when react plugin is available
const resolved = await this.resolve('/@react-refresh')
if (resolved) {
code += `
import RefreshRuntime from "/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
`
}
code += `await import(${JSON.stringify(resolvedId)});`
// Server CSS cleanup on HMR
code += /* js */ `
const ssrCss = document.querySelectorAll("link[rel='stylesheet']");
import.meta.hot.on("vite:beforeUpdate", () => {
ssrCss.forEach(node => {
if (node.dataset.precedence?.startsWith("vite-rsc/client-references")) {
node.remove();
}
});
});
`
// Close error overlay after syntax error is fixed
code += `
import.meta.hot.on("rsc:update", () => {
document.querySelectorAll("vite-error-overlay").forEach((n) => n.close())
});
`
return code
}
},
buildStart() {
// Emit discovered entries during build
if (this.environment.mode !== 'build') return
if (this.environment.name !== 'client') return

// Collect unique entries targeting client environment
const emitted = new Set<string>()
for (const metas of Object.values(manager.assetImportMetaMap)) {
for (const meta of Object.values(metas)) {
if (meta.isEntry && !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.importAsset')) return

const { server, config } = manager
const s = new MagicString(code)

for (const match of stripLiteral(code).matchAll(
/import\.meta\.viteRsc\.importAsset\s*\(([\s\S]*?)\)/dg,
)) {
const [argStart, argEnd] = match.indices![1]!
const argCode = code.slice(argStart, argEnd).trim()

// Parse: ('./entry.browser.tsx', { entry: true })
const [specifier, options]: [string, { entry?: boolean }?] =
evalValue(`[${argCode}]`)
const isEntry = options?.entry ?? false

// Resolve specifier relative to importer against client environment
let resolvedId: string
if (this.environment.mode === 'dev') {
const clientEnv = server.environments.client
assert(clientEnv, `[vite-rsc] client environment not found`)
const resolved = await clientEnv.pluginContainer.resolveId(
specifier,
id,
)
assert(
resolved,
`[vite-rsc] failed to resolve '${specifier}' for client environment`,
)
resolvedId = resolved.id
} else {
// Build mode: resolve in client environment config
const clientEnvConfig = config.environments.client
assert(clientEnvConfig, `[vite-rsc] client environment not found`)
// Use this environment's resolver for now
const resolved = await this.resolve(specifier, id)
assert(
resolved,
`[vite-rsc] failed to resolve '${specifier}' for client environment`,
)
resolvedId = resolved.id
}

// Track discovered asset, keyed by [sourceEnv][resolvedId]
const sourceEnv = this.environment.name
manager.assetImportMetaMap[sourceEnv] ??= {}
manager.assetImportMetaMap[sourceEnv]![resolvedId] = {
resolvedId,
sourceEnv,
specifier,
isEntry,
}

let replacement: string
if (this.environment.mode === 'dev') {
if (isEntry) {
// Dev + entry: use virtual wrapper with HMR support
const virtualId = ASSET_ENTRY_VIRTUAL_PREFIX + resolvedId
const url = config.base + '@id/__x00__' + virtualId
replacement = `Promise.resolve({ url: ${JSON.stringify(url)} })`
} else {
// Dev + non-entry: compute URL directly
const relativePath = normalizePath(
path.relative(config.root, resolvedId),
)
const url = config.base + relativePath
replacement = `Promise.resolve({ url: ${JSON.stringify(url)} })`
}
} else {
// Build: use existing assets manifest
// Use relative ID for stable builds across different machines
const relativeId = manager.toRelativeId(resolvedId)
replacement = `(async () => (await import("virtual:vite-rsc/assets-manifest")).default.importAssets[${JSON.stringify(relativeId)}])()`

Check warning

Code scanning / CodeQL

Improper code sanitization Medium

Code construction depends on an
improperly sanitized value
.

Copilot Autofix

AI 3 months ago

In general, whenever user-influenced strings are embedded into dynamically constructed JavaScript source, you must escape additional “unsafe” characters beyond what JSON.stringify handles for HTML/script contexts, particularly <, >, /, backslash, control characters, and U+2028/U+2029. The referenced pattern uses a small helper (escapeUnsafeChars) applied after JSON.stringify to ensure the resulting string literal is safe even when inlined into <script>.

The best targeted fix here is to introduce a small local escape helper in packages/plugin-rsc/src/plugins/import-asset.ts and apply it to JSON.stringify(relativeId) in the build-mode replacement expression. We will: (1) define a charMap and escapeUnsafeChars function near the top of this file, using the same mapping as in the background, and (2) change line 188 so that it uses escapeUnsafeChars(JSON.stringify(relativeId)). This preserves all existing behavior (the manifest still uses the same key value from relativeId) while ensuring the generated virtual module source cannot contain problematic raw characters in that location.

No new external imports are needed; we can implement the helper with String.prototype.replace. All edits stay within packages/plugin-rsc/src/plugins/import-asset.ts in the provided regions.

Suggested changeset 1
packages/plugin-rsc/src/plugins/import-asset.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/plugin-rsc/src/plugins/import-asset.ts b/packages/plugin-rsc/src/plugins/import-asset.ts
--- a/packages/plugin-rsc/src/plugins/import-asset.ts
+++ b/packages/plugin-rsc/src/plugins/import-asset.ts
@@ -14,6 +14,28 @@
 const ASSET_IMPORTS_CLIENT_ENTRY_FALLBACK =
   'virtual:vite-rsc/asset-imports-client-entry-fallback'
 
+const __unsafeCharMap: Record<string, string> = {
+  '<': '\\u003C',
+  '>': '\\u003E',
+  '/': '\\u002F',
+  '\\': '\\\\',
+  '\b': '\\b',
+  '\f': '\\f',
+  '\n': '\\n',
+  '\r': '\\r',
+  '\t': '\\t',
+  '\0': '\\0',
+  '\u2028': '\\u2028',
+  '\u2029': '\\u2029',
+}
+
+function escapeUnsafeChars(str: string): string {
+  return str.replace(
+    /[<>/\\\b\f\n\r\t\0\u2028\u2029]/g,
+    (ch) => __unsafeCharMap[ch] ?? ch,
+  )
+}
+
 export type AssetImportMeta = {
   resolvedId: string
   sourceEnv: string
@@ -185,7 +207,9 @@
               // Build: use existing assets manifest
               // Use relative ID for stable builds across different machines
               const relativeId = manager.toRelativeId(resolvedId)
-              replacement = `(async () => (await import("virtual:vite-rsc/assets-manifest")).default.importAssets[${JSON.stringify(relativeId)}])()`
+              replacement = `(async () => (await import("virtual:vite-rsc/assets-manifest")).default.importAssets[${escapeUnsafeChars(
+                JSON.stringify(relativeId),
+              )}])()`
             }
 
             const [start, end] = match.indices![0]!
EOF
@@ -14,6 +14,28 @@
const ASSET_IMPORTS_CLIENT_ENTRY_FALLBACK =
'virtual:vite-rsc/asset-imports-client-entry-fallback'

const __unsafeCharMap: Record<string, string> = {
'<': '\\u003C',
'>': '\\u003E',
'/': '\\u002F',
'\\': '\\\\',
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\0': '\\0',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
}

function escapeUnsafeChars(str: string): string {
return str.replace(
/[<>/\\\b\f\n\r\t\0\u2028\u2029]/g,
(ch) => __unsafeCharMap[ch] ?? ch,
)
}

export type AssetImportMeta = {
resolvedId: string
sourceEnv: string
@@ -185,7 +207,9 @@
// Build: use existing assets manifest
// Use relative ID for stable builds across different machines
const relativeId = manager.toRelativeId(resolvedId)
replacement = `(async () => (await import("virtual:vite-rsc/assets-manifest")).default.importAssets[${JSON.stringify(relativeId)}])()`
replacement = `(async () => (await import("virtual:vite-rsc/assets-manifest")).default.importAssets[${escapeUnsafeChars(
JSON.stringify(relativeId),
)}])()`
}

const [start, end] = match.indices![0]!
Copilot is powered by AI and may make mistakes. Always verify output.
}

const [start, end] = match.indices![0]!
s.overwrite(start, end, replacement)
}

if (s.hasChanged()) {
return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary' }),
}
}
},
},
},
]
}
23 changes: 23 additions & 0 deletions packages/plugin-rsc/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ declare global {
specifier: string,
options: { environment: string },
) => Promise<T>

/**
* Import a client asset from a server environment (SSR/RSC) and get its URL.
*
* This is useful for loading client-side scripts and obtaining their URLs
* for bootstrap scripts or other dynamic imports.
*
* @example
* ```ts
* const asset = await import.meta.viteRsc.importAsset('./entry.browser.tsx', { entry: true });
* const bootstrapScriptContent = `import(${JSON.stringify(asset.url)})`;
* ```
*
* @param specifier - Relative path to the client asset (e.g., './entry.browser.tsx')
* @param options - Options object
* @param options.entry - When true, marks this asset as an entry point for client deps merging.
* This replaces the "index" entry convention when using customClientEntry.
* @returns Promise resolving to an object containing the asset URL
*/
importAsset: (
specifier: string,
options?: { entry?: boolean },
) => Promise<{ url: string }>
}
}

Expand Down
Loading