From c15c99ce9446268109924e20795bfe5b9f34adb3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 19 Jan 2026 18:34:41 +0900 Subject: [PATCH 1/6] feat(plugin-rsc): add `import.meta.viteRsc.importAsset` API Add a new `importAsset` API that allows importing client assets from server environments (SSR/RSC), returning the asset URL. This provides a more flexible, specifier-based approach that can eventually replace `loadBootstrapScriptContent`. API: `importAsset(specifier, options?) => Promise<{ url: string }>` Features: - Dev mode with `entry: true`: Uses virtual wrapper with HMR support - Dev mode with `entry: false`: Returns direct file URL - Build mode: Returns URL from generated manifest Co-Authored-By: Claude Opus 4.5 --- .../basic/src/framework/entry.ssr.tsx | 5 + packages/plugin-rsc/src/plugin.ts | 26 ++ .../plugin-rsc/src/plugins/import-asset.ts | 245 ++++++++++++++++++ packages/plugin-rsc/types/index.d.ts | 23 ++ 4 files changed, 299 insertions(+) create mode 100644 packages/plugin-rsc/src/plugins/import-asset.ts diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx index ab4df6ea7..88fdb2b92 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx @@ -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') diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index c000ff035..750eca93a 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -27,6 +27,11 @@ import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' +import { + vitePluginImportAsset, + writeAssetImportsManifest, + type AssetImportMeta, +} from './plugins/import-asset' import { ensureEnvironmentImportsEntryFallback, vitePluginImportEnvironment, @@ -136,6 +141,13 @@ class RscPluginManager { > > > = {} + assetImportMetaMap: Record< + string, // sourceEnv + Record< + string, // resolvedId + AssetImportMeta + > + > = {} stabilize(): void { // sort for stable build @@ -165,6 +177,18 @@ class RscPluginManager { writeEnvironmentImportsManifest(): void { writeEnvironmentImportsManifest(this) } + + writeAssetImportsManifest(): void { + writeAssetImportsManifest(this) + } + + assetsURL(url: string): string | RuntimeAsset { + return assetsURL(url, this) + } + + serializeValueWithRuntime(value: any): string { + return serializeValueWithRuntime(value) + } } export type RscPluginOptions = { @@ -433,6 +457,7 @@ export default function vitePluginRsc( manager.writeAssetsManifest(['ssr', 'rsc']) manager.writeEnvironmentImportsManifest() + manager.writeAssetImportsManifest() } let hasReactServerDomWebpack = false @@ -1220,6 +1245,7 @@ import.meta.hot.on("rsc:update", () => { ), ...vitePluginRscMinimal(rscPluginOptions, manager), ...vitePluginImportEnvironment(manager), + ...vitePluginImportAsset(manager), ...vitePluginFindSourceMapURL(), ...vitePluginRscCss(rscPluginOptions, manager), { diff --git a/packages/plugin-rsc/src/plugins/import-asset.ts b/packages/plugin-rsc/src/plugins/import-asset.ts new file mode 100644 index 000000000..936d5c34d --- /dev/null +++ b/packages/plugin-rsc/src/plugins/import-asset.ts @@ -0,0 +1,245 @@ +import assert from 'node:assert' +import fs from 'node:fs' +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 { normalizeRelativePath } from './utils' +import { evalValue } from './vite-utils' + +export const ASSET_IMPORTS_MANIFEST_NAME = + '__vite_rsc_asset_imports_manifest.js' + +const ASSET_IMPORTS_MANIFEST_PLACEHOLDER = + 'virtual:vite-rsc/asset-imports-manifest' + +// 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) { + // Use placeholder as external, renderChunk will replace with correct relative path + if (source === ASSET_IMPORTS_MANIFEST_PLACEHOLDER) { + return { id: ASSET_IMPORTS_MANIFEST_PLACEHOLDER, external: true } + } + // 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() + 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: emit manifest lookup with static import + // Use relative ID for stable builds across different machines + const relativeId = manager.toRelativeId(resolvedId) + replacement = `(await import(${JSON.stringify(ASSET_IMPORTS_MANIFEST_PLACEHOLDER)})).default[${JSON.stringify(relativeId)}]` + } + + const [start, end] = match.indices![0]! + s.overwrite(start, end, replacement) + } + + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + } + }, + }, + + renderChunk(code, chunk) { + if (code.includes(ASSET_IMPORTS_MANIFEST_PLACEHOLDER)) { + const replacement = normalizeRelativePath( + path.relative( + path.join(chunk.fileName, '..'), + ASSET_IMPORTS_MANIFEST_NAME, + ), + ) + code = code.replaceAll( + ASSET_IMPORTS_MANIFEST_PLACEHOLDER, + () => replacement, + ) + return { code } + } + return + }, + }, + ] +} + +export function writeAssetImportsManifest(manager: RscPluginManager): void { + if (Object.keys(manager.assetImportMetaMap).length === 0) { + return + } + + const clientBundle = manager.bundles['client'] + if (!clientBundle) { + throw new Error(`[vite-rsc] missing client bundle for asset imports`) + } + + // Write manifest to each source environment's output + for (const [sourceEnv, metas] of Object.entries(manager.assetImportMetaMap)) { + const sourceOutDir = manager.config.environments[sourceEnv]!.build.outDir + const manifestPath = path.join(sourceOutDir, ASSET_IMPORTS_MANIFEST_NAME) + + let code = 'export default {\n' + for (const resolvedId of Object.keys(metas)) { + const chunk = Object.values(clientBundle).find( + (c) => c.type === 'chunk' && c.facadeModuleId === resolvedId, + ) + if (!chunk) { + throw new Error( + `[vite-rsc] missing output for asset import: ${resolvedId}`, + ) + } + + const relativeId = manager.toRelativeId(resolvedId) + const url = manager.assetsURL(chunk.fileName) + code += ` ${JSON.stringify(relativeId)}: ${manager.serializeValueWithRuntime({ url })},\n` + } + code += '}\n' + + fs.writeFileSync(manifestPath, code) + } +} diff --git a/packages/plugin-rsc/types/index.d.ts b/packages/plugin-rsc/types/index.d.ts index 5eafb4ee1..bdedd6a01 100644 --- a/packages/plugin-rsc/types/index.d.ts +++ b/packages/plugin-rsc/types/index.d.ts @@ -27,6 +27,29 @@ declare global { specifier: string, options: { environment: string }, ) => Promise + + /** + * 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 }> } } From 08e88f35bfc0b2955cc6876c1916a80808f2376a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 19 Jan 2026 18:42:14 +0900 Subject: [PATCH 2/6] refactor: consolidate importAssets into existing AssetsManifest Instead of generating a separate __vite_rsc_asset_imports_manifest.js file, include importAssets in the existing __vite_rsc_assets_manifest.js as AssetsManifest.importAssets. Co-Authored-By: Claude Opus 4.5 --- packages/plugin-rsc/src/plugin.ts | 35 ++++++---- .../plugin-rsc/src/plugins/import-asset.ts | 69 +------------------ 2 files changed, 23 insertions(+), 81 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 750eca93a..e03e63d4d 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -29,7 +29,6 @@ import { cjsModuleRunnerPlugin } from './plugins/cjs' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' import { vitePluginImportAsset, - writeAssetImportsManifest, type AssetImportMeta, } from './plugins/import-asset' import { @@ -177,18 +176,6 @@ class RscPluginManager { writeEnvironmentImportsManifest(): void { writeEnvironmentImportsManifest(this) } - - writeAssetImportsManifest(): void { - writeAssetImportsManifest(this) - } - - assetsURL(url: string): string | RuntimeAsset { - return assetsURL(url, this) - } - - serializeValueWithRuntime(value: any): string { - return serializeValueWithRuntime(value) - } } export type RscPluginOptions = { @@ -457,7 +444,6 @@ export default function vitePluginRsc( manager.writeAssetsManifest(['ssr', 'rsc']) manager.writeEnvironmentImportsManifest() - manager.writeAssetImportsManifest() } let hasReactServerDomWebpack = false @@ -1114,11 +1100,30 @@ export function createRpcClient(params) { `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, ) } + // Compute importAssets from assetImportMetaMap + const importAssets: Record = + {} + 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, } } }, @@ -2016,6 +2021,7 @@ export type AssetsManifest = { clientReferenceDeps: Record serverResources?: Record> cssLinkPrecedence?: boolean + importAssets?: Record } export type AssetDeps = { @@ -2028,6 +2034,7 @@ export type ResolvedAssetsManifest = { clientReferenceDeps: Record serverResources?: Record> cssLinkPrecedence?: boolean + importAssets?: Record } export type ResolvedAssetDeps = { diff --git a/packages/plugin-rsc/src/plugins/import-asset.ts b/packages/plugin-rsc/src/plugins/import-asset.ts index 936d5c34d..1822bc4b9 100644 --- a/packages/plugin-rsc/src/plugins/import-asset.ts +++ b/packages/plugin-rsc/src/plugins/import-asset.ts @@ -1,19 +1,11 @@ import assert from 'node:assert' -import fs from 'node:fs' 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 { normalizeRelativePath } from './utils' import { evalValue } from './vite-utils' -export const ASSET_IMPORTS_MANIFEST_NAME = - '__vite_rsc_asset_imports_manifest.js' - -const ASSET_IMPORTS_MANIFEST_PLACEHOLDER = - 'virtual:vite-rsc/asset-imports-manifest' - // Virtual module prefix for entry asset wrappers in dev mode const ASSET_ENTRY_VIRTUAL_PREFIX = 'virtual:vite-rsc/asset-entry/' @@ -29,10 +21,6 @@ export function vitePluginImportAsset(manager: RscPluginManager): Plugin[] { { name: 'rsc:import-asset', resolveId(source) { - // Use placeholder as external, renderChunk will replace with correct relative path - if (source === ASSET_IMPORTS_MANIFEST_PLACEHOLDER) { - return { id: ASSET_IMPORTS_MANIFEST_PLACEHOLDER, external: true } - } // Handle virtual asset entry modules if (source.startsWith(ASSET_ENTRY_VIRTUAL_PREFIX)) { return '\0' + source @@ -169,10 +157,10 @@ import.meta.hot.on("rsc:update", () => { replacement = `Promise.resolve({ url: ${JSON.stringify(url)} })` } } else { - // Build: emit manifest lookup with static import + // Build: use existing assets manifest // Use relative ID for stable builds across different machines const relativeId = manager.toRelativeId(resolvedId) - replacement = `(await import(${JSON.stringify(ASSET_IMPORTS_MANIFEST_PLACEHOLDER)})).default[${JSON.stringify(relativeId)}]` + replacement = `(async () => (await import("virtual:vite-rsc/assets-manifest")).default.importAssets[${JSON.stringify(relativeId)}])()` } const [start, end] = match.indices![0]! @@ -187,59 +175,6 @@ import.meta.hot.on("rsc:update", () => { } }, }, - - renderChunk(code, chunk) { - if (code.includes(ASSET_IMPORTS_MANIFEST_PLACEHOLDER)) { - const replacement = normalizeRelativePath( - path.relative( - path.join(chunk.fileName, '..'), - ASSET_IMPORTS_MANIFEST_NAME, - ), - ) - code = code.replaceAll( - ASSET_IMPORTS_MANIFEST_PLACEHOLDER, - () => replacement, - ) - return { code } - } - return - }, }, ] } - -export function writeAssetImportsManifest(manager: RscPluginManager): void { - if (Object.keys(manager.assetImportMetaMap).length === 0) { - return - } - - const clientBundle = manager.bundles['client'] - if (!clientBundle) { - throw new Error(`[vite-rsc] missing client bundle for asset imports`) - } - - // Write manifest to each source environment's output - for (const [sourceEnv, metas] of Object.entries(manager.assetImportMetaMap)) { - const sourceOutDir = manager.config.environments[sourceEnv]!.build.outDir - const manifestPath = path.join(sourceOutDir, ASSET_IMPORTS_MANIFEST_NAME) - - let code = 'export default {\n' - for (const resolvedId of Object.keys(metas)) { - const chunk = Object.values(clientBundle).find( - (c) => c.type === 'chunk' && c.facadeModuleId === resolvedId, - ) - if (!chunk) { - throw new Error( - `[vite-rsc] missing output for asset import: ${resolvedId}`, - ) - } - - const relativeId = manager.toRelativeId(resolvedId) - const url = manager.assetsURL(chunk.fileName) - code += ` ${JSON.stringify(relativeId)}: ${manager.serializeValueWithRuntime({ url })},\n` - } - code += '}\n' - - fs.writeFileSync(manifestPath, code) - } -} From 8b951af4b4a81a00908426db4d227d4f6a3821ad Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 19 Jan 2026 18:44:39 +0900 Subject: [PATCH 3/6] fix: add ensureAssetImportsClientEntry for client rollupOptions.input Ensure the client environment has at least one entry when no other entries exist, similar to how import-environment.ts handles non-client environments. Co-Authored-By: Claude Opus 4.5 --- packages/plugin-rsc/src/plugin.ts | 2 ++ .../plugin-rsc/src/plugins/import-asset.ts | 33 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index e03e63d4d..92b62341b 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -28,6 +28,7 @@ import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' import { + ensureAssetImportsClientEntry, vitePluginImportAsset, type AssetImportMeta, } from './plugins/import-asset' @@ -405,6 +406,7 @@ export default function vitePluginRsc( // rsc -> ssr -> rsc -> client -> ssr ensureEnvironmentImportsEntryFallback(builder.config) + ensureAssetImportsClientEntry(builder.config) manager.isScanBuild = true builder.environments.rsc!.config.build.write = false builder.environments.ssr!.config.build.write = false diff --git a/packages/plugin-rsc/src/plugins/import-asset.ts b/packages/plugin-rsc/src/plugins/import-asset.ts index 1822bc4b9..235255562 100644 --- a/packages/plugin-rsc/src/plugins/import-asset.ts +++ b/packages/plugin-rsc/src/plugins/import-asset.ts @@ -2,13 +2,18 @@ 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 { normalizePath, type Plugin, type ResolvedConfig } from 'vite' import type { RscPluginManager } from '../plugin' +import { createVirtualPlugin, normalizeRollupOpitonsInput } from './utils' 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/' +// Fallback entry for client environment when no other entries exist +const ASSET_IMPORTS_CLIENT_ENTRY_FALLBACK = + 'virtual:vite-rsc/asset-imports-client-entry-fallback' + export type AssetImportMeta = { resolvedId: string sourceEnv: string @@ -16,6 +21,26 @@ export type AssetImportMeta = { isEntry: boolean } +// Ensure client environment has at least one entry since otherwise rollup build fails +export function ensureAssetImportsClientEntry({ + environments, +}: ResolvedConfig): void { + const clientConfig = environments.client + if (!clientConfig) return + + const input = normalizeRollupOpitonsInput( + clientConfig.build?.rollupOptions?.input, + ) + if (Object.keys(input).length === 0) { + clientConfig.build = clientConfig.build || {} + clientConfig.build.rollupOptions = clientConfig.build.rollupOptions || {} + clientConfig.build.rollupOptions.input = { + __vite_rsc_asset_imports_client_entry_fallback: + ASSET_IMPORTS_CLIENT_ENTRY_FALLBACK, + } + } +} + export function vitePluginImportAsset(manager: RscPluginManager): Plugin[] { return [ { @@ -176,5 +201,11 @@ import.meta.hot.on("rsc:update", () => { }, }, }, + createVirtualPlugin( + ASSET_IMPORTS_CLIENT_ENTRY_FALLBACK.slice('virtual:'.length), + () => { + return `export default "__vite_rsc_asset_imports_client_entry_fallback";` + }, + ), ] } From 96e039149a0e49649cff797bb5c6f780300e17c0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 19 Jan 2026 18:46:58 +0900 Subject: [PATCH 4/6] test: add e2e test for importAsset API Test that importAsset can replace loadBootstrapScriptContent for loading client entry URLs in both dev and build modes. Co-Authored-By: Claude Opus 4.5 --- packages/plugin-rsc/e2e/import-asset.test.ts | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/plugin-rsc/e2e/import-asset.test.ts diff --git a/packages/plugin-rsc/e2e/import-asset.test.ts b/packages/plugin-rsc/e2e/import-asset.test.ts new file mode 100644 index 000000000..1b4221a2f --- /dev/null +++ b/packages/plugin-rsc/e2e/import-asset.test.ts @@ -0,0 +1,38 @@ +import { test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' + +test.describe('viteRsc.importAsset', () => { + const root = 'examples/e2e/temp/import-asset' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/framework/entry.ssr.tsx': { + edit: (s) => + s.replace( + `\ + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') +`, + `\ + const asset = await import.meta.viteRsc.importAsset('./entry.browser.tsx', { entry: true }) + const bootstrapScriptContent = \`import(\${JSON.stringify(asset.url)})\` +`, + ), + }, + }, + }) + }) + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineStarterTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + }) +}) From 29562fbfc0b84fb31a94c60e7575f080e648a1ec Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 19 Jan 2026 18:54:09 +0900 Subject: [PATCH 5/6] wip --- packages/plugin-rsc/e2e/import-asset.test.ts | 7 ++ packages/plugin-rsc/src/plugin.ts | 97 +++++++++++++++----- 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/packages/plugin-rsc/e2e/import-asset.test.ts b/packages/plugin-rsc/e2e/import-asset.test.ts index 1b4221a2f..11c92b85e 100644 --- a/packages/plugin-rsc/e2e/import-asset.test.ts +++ b/packages/plugin-rsc/e2e/import-asset.test.ts @@ -22,6 +22,13 @@ test.describe('viteRsc.importAsset', () => { `, ), }, + // Remove "index" client entry to test importAsset replacing the convention + 'vite.config.base.ts': { cp: 'vite.config.ts' }, + 'vite.config.ts': /* js */ ` + import baseConfig from './vite.config.base.ts' + delete baseConfig.environments.client.build.rollupOptions.input; + export default baseConfig; + `, }, }) }) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 92b62341b..f3d37a85c 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -1078,30 +1078,34 @@ export function createRpcClient(params) { } const assetDeps = collectAssetDeps(bundle) - const entry = Object.values(assetDeps).find( - (v) => v.chunk.name === 'index' && v.chunk.isEntry, - ) - assert(entry) - const entryUrl = assetsURL(entry.chunk.fileName, manager) - const clientReferenceDeps: Record = {} - for (const meta of Object.values(manager.clientReferenceMetaMap)) { - const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? { - js: [], - css: [], + + // Check if there are any importAsset entries with isEntry: true + const importAssetEntries: Array<{ + resolvedId: string + chunk: Rollup.OutputChunk + deps: AssetDeps + }> = [] + for (const metas of Object.values(manager.assetImportMetaMap)) { + for (const [resolvedId, meta] of Object.entries(metas)) { + if (meta.isEntry) { + const chunk = Object.values(bundle).find( + (c): c is Rollup.OutputChunk => + c.type === 'chunk' && c.facadeModuleId === resolvedId, + ) + if (chunk) { + const chunkDeps = assetDeps[chunk.fileName] + if (chunkDeps) { + importAssetEntries.push({ + resolvedId, + chunk, + deps: chunkDeps.deps, + }) + } + } + } } - clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( - mergeAssetDeps(deps, entry.deps), - manager, - ) - } - let bootstrapScriptContent: string | RuntimeAsset - if (typeof entryUrl === 'string') { - bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})` - } else { - bootstrapScriptContent = new RuntimeAsset( - `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, - ) } + // Compute importAssets from assetImportMetaMap const importAssets: Record = {} @@ -1119,6 +1123,55 @@ export function createRpcClient(params) { } } + let bootstrapScriptContent: string | RuntimeAsset = '' + const clientReferenceDeps: Record = {} + + if (importAssetEntries.length > 0) { + // Use importAsset entries for merging deps into client references + // Merge all entry deps together + let entryDeps: AssetDeps = { js: [], css: [] } + for (const entry of importAssetEntries) { + entryDeps = mergeAssetDeps(entryDeps, entry.deps) + } + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? { + js: [], + css: [], + } + clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( + mergeAssetDeps(deps, entryDeps), + manager, + ) + } + } else { + // Fall back to "index" entry convention + const entry = Object.values(assetDeps).find( + (v) => v.chunk.name === 'index' && v.chunk.isEntry, + ) + assert( + entry, + `[vite-rsc] missing "index" entry. Use importAsset with { entry: true } or configure client entry.`, + ) + const entryUrl = assetsURL(entry.chunk.fileName, manager) + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? { + js: [], + css: [], + } + clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( + mergeAssetDeps(deps, entry.deps), + manager, + ) + } + if (typeof entryUrl === 'string') { + bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})` + } else { + bootstrapScriptContent = new RuntimeAsset( + `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, + ) + } + } + manager.buildAssetsManifest = { bootstrapScriptContent, clientReferenceDeps, From bef2c8f2989b3396be5c6ce8e15c8baa3280227e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 19 Jan 2026 18:54:48 +0900 Subject: [PATCH 6/6] chore: example --- .../plugin-rsc/examples/basic/src/framework/entry.ssr.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx index 88fdb2b92..89af64b80 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx @@ -27,14 +27,11 @@ export async function renderHTML( return React.use(payload).root } + // render html (traditional SSR) 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') + const bootstrapScriptContent = `import(${JSON.stringify(asset.url)})` let htmlStream: ReadableStream let status: number | undefined try {