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..11c92b85e --- /dev/null +++ b/packages/plugin-rsc/e2e/import-asset.test.ts @@ -0,0 +1,45 @@ +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)})\` +`, + ), + }, + // 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; + `, + }, + }) + }) + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineStarterTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + }) +}) 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..89af64b80 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx @@ -28,8 +28,10 @@ export async function renderHTML( } // render html (traditional SSR) - 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)})` let htmlStream: ReadableStream let status: number | undefined try { diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index c000ff035..f3d37a85c 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 { + ensureAssetImportsClientEntry, + vitePluginImportAsset, + 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 @@ -394,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 @@ -1065,35 +1078,107 @@ 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, + }) + } + } + } + } + } + + // 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), + } + } } - clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( - mergeAssetDeps(deps, entry.deps), - manager, - ) } - let bootstrapScriptContent: string | RuntimeAsset - if (typeof entryUrl === 'string') { - bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})` + + 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 { - bootstrapScriptContent = new RuntimeAsset( - `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, + // 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, serverResources, cssLinkPrecedence: rscPluginOptions.cssLinkPrecedence, + importAssets: + Object.keys(importAssets).length > 0 ? importAssets : undefined, } } }, @@ -1220,6 +1305,7 @@ import.meta.hot.on("rsc:update", () => { ), ...vitePluginRscMinimal(rscPluginOptions, manager), ...vitePluginImportEnvironment(manager), + ...vitePluginImportAsset(manager), ...vitePluginFindSourceMapURL(), ...vitePluginRscCss(rscPluginOptions, manager), { @@ -1990,6 +2076,7 @@ export type AssetsManifest = { clientReferenceDeps: Record serverResources?: Record> cssLinkPrecedence?: boolean + importAssets?: Record } export type AssetDeps = { @@ -2002,6 +2089,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 new file mode 100644 index 000000000..235255562 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/import-asset.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert' +import path from 'node:path' +import MagicString from 'magic-string' +import { stripLiteral } from 'strip-literal' +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 + specifier: string + 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 [ + { + 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() + 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)}])()` + } + + const [start, end] = match.indices![0]! + s.overwrite(start, end, replacement) + } + + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + } + }, + }, + }, + createVirtualPlugin( + ASSET_IMPORTS_CLIENT_ENTRY_FALLBACK.slice('virtual:'.length), + () => { + return `export default "__vite_rsc_asset_imports_client_entry_fallback";` + }, + ), + ] +} 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 }> } }