diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index f5b883246..47429e119 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -200,7 +200,10 @@ function editFileJson(filepath: string, edit: (s: string) => string) { export async function setupInlineFixture(options: { src: string dest: string - files?: Record + files?: Record< + string, + string | { cp: string } | { edit: (s: string) => string } + > }) { fs.rmSync(options.dest, { recursive: true, force: true }) fs.mkdirSync(options.dest, { recursive: true }) @@ -223,6 +226,11 @@ export async function setupInlineFixture(options: { fs.copyFileSync(srcFile, destFile) continue } + if (typeof contents === 'object' && 'edit' in contents) { + const editted = contents.edit(fs.readFileSync(destFile, 'utf-8')) + fs.writeFileSync(destFile, editted) + continue + } // write a new file contents = contents.replace(/^\n*/, '').replace(/\s*$/, '\n') diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts index 6d80bc986..df0d93b0b 100644 --- a/packages/plugin-rsc/e2e/starter.test.ts +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -229,6 +229,165 @@ test.describe(() => { }) }) +test.describe(() => { + const root = 'examples/e2e/temp/renderBuiltUrl-runtime' + + test.beforeAll(async () => { + const renderBuiltUrl = (filename: string) => { + return { + runtime: `__dynamicBase + ${JSON.stringify(filename)}`, + } + } + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.ts': /* js */ ` + import rsc from '@vitejs/plugin-rsc' + import react from '@vitejs/plugin-react' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/framework/entry.rsc.tsx', + } + }), + { + // simulate custom asset server + name: 'custom-server', + config(_config, env) { + if (env.isPreview) { + globalThis.__dynamicBase = '/custom-server/'; + } + }, + configurePreviewServer(server) { + server.middlewares.use((req, res, next) => { + const url = new URL(req.url ?? '', "http://localhost"); + if (url.pathname.startsWith('/custom-server/')) { + req.url = url.pathname.replace('/custom-server/', '/'); + } + next(); + }); + } + } + ], + // tweak chunks to test "__dynamicBase" used on browser for "__vite__mapDeps" + environments: { + client: { + build: { + rollupOptions: { + output: { + manualChunks: (id) => { + if (id.includes('node_modules/react/')) { + return 'lib-react'; + } + } + }, + } + } + } + }, + experimental: { + renderBuiltUrl: ${renderBuiltUrl.toString()} + }, + }) + `, + 'src/root.tsx': { + // define __dynamicBase on browser via head script + edit: (s: string) => + s.replace( + '', + () => + ``, + ), + }, + }, + }) + }) + + test.describe('dev-renderBuiltUrl-runtime', () => { + const f = useFixture({ root, mode: 'dev' }) + + test('basic', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration_(page) + }) + }) + + test.describe('build-renderBuiltUrl-runtime', () => { + const f = useFixture({ root, mode: 'build' }) + defineTest(f) + + test('verify runtime url', () => { + const manifestFileContent = fs.readFileSync( + f.root + '/dist/ssr/__vite_rsc_assets_manifest.js', + 'utf-8', + ) + expect(manifestFileContent).toContain(`__dynamicBase + "assets/client-`) + }) + }) +}) + +test.describe(() => { + const root = 'examples/e2e/temp/renderBuiltUrl-string' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.ts': /* js */ ` + import rsc from '@vitejs/plugin-rsc' + import react from '@vitejs/plugin-react' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/framework/entry.rsc.tsx', + } + }), + { + // simulate custom asset server + name: 'custom-server', + configurePreviewServer(server) { + server.middlewares.use((req, res, next) => { + const url = new URL(req.url ?? '', "http://localhost"); + if (url.pathname.startsWith('/custom-server/')) { + req.url = url.pathname.replace('/custom-server/', '/'); + } + next(); + }); + } + } + ], + experimental: { + renderBuiltUrl(filename) { + return '/custom-server/' + filename; + } + } + }) + `, + }, + }) + }) + + test.describe('build-renderBuiltUrl-string', () => { + const f = useFixture({ root, mode: 'build' }) + defineTest(f) + }) +}) + function defineTest(f: Fixture, variant?: 'no-ssr' | 'dev-production') { const waitForHydration: typeof waitForHydration_ = (page) => waitForHydration_(page, variant === 'no-ssr' ? '#root' : 'body') diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 8f7f7f0f3..0ef7db0b8 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -242,10 +242,8 @@ export default function vitePluginRsc( serverResourcesMetaMap = sortObject(serverResourcesMetaMap) await builder.build(builder.environments.client!) - const assetsManifestCode = `export default ${JSON.stringify( + const assetsManifestCode = `export default ${serializeValueWithRuntime( buildAssetsManifest, - null, - 2, )}` const manifestPath = path.join( builder.environments!.rsc!.config.build!.outDir!, @@ -586,7 +584,7 @@ export default function vitePluginRsc( assert(this.environment.mode === 'dev') const entryUrl = assetsURL('@id/__x00__' + VIRTUAL_ENTRIES.browser) const manifest: AssetsManifest = { - bootstrapScriptContent: `import(${JSON.stringify(entryUrl)})`, + bootstrapScriptContent: `import(${serializeValueWithRuntime(entryUrl)})`, clientReferenceDeps: {}, } return `export default ${JSON.stringify(manifest, null, 2)}` @@ -640,8 +638,16 @@ export default function vitePluginRsc( mergeAssetDeps(deps, entry.deps), ) } + let bootstrapScriptContent: string | RuntimeAsset + if (typeof entryUrl === 'string') { + bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})` + } else { + bootstrapScriptContent = new RuntimeAsset( + `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, + ) + } buildAssetsManifest = { - bootstrapScriptContent: `import(${JSON.stringify(entryUrl)})`, + bootstrapScriptContent, clientReferenceDeps, serverResources, } @@ -671,10 +677,8 @@ export default function vitePluginRsc( if (this.environment.name === 'ssr') { // output client manifest to non-client build directly. // this makes server build to be self-contained and deploy-able for cloudflare. - const assetsManifestCode = `export default ${JSON.stringify( + const assetsManifestCode = `export default ${serializeValueWithRuntime( buildAssetsManifest, - null, - 2, )}` for (const name of ['ssr', 'rsc']) { const manifestPath = path.join( @@ -1273,15 +1277,76 @@ function generateDynamicImportCode(map: Record) { return `export default {${code}};\n` } -// // https://github.com/vitejs/vite/blob/2a7473cfed96237711cda9f736465c84d442ddef/packages/vite/src/node/plugins/importAnalysisBuild.ts#L222-L230 +class RuntimeAsset { + runtime: string + constructor(value: string) { + this.runtime = value + } +} + +function serializeValueWithRuntime(value: any) { + const replacements: [string, string][] = [] + let result = JSON.stringify( + value, + (_key, value) => { + if (value instanceof RuntimeAsset) { + const placeholder = `__runtime_placeholder_${replacements.length}__` + replacements.push([placeholder, value.runtime]) + return placeholder + } + + return value + }, + 2, + ) + + for (const [placeholder, runtime] of replacements) { + result = result.replace(`"${placeholder}"`, runtime) + } + + return result +} + function assetsURL(url: string) { + if ( + config.command === 'build' && + typeof config.experimental?.renderBuiltUrl === 'function' + ) { + // https://github.com/vitejs/vite/blob/bdde0f9e5077ca1a21a04eefc30abad055047226/packages/vite/src/node/build.ts#L1369 + const result = config.experimental.renderBuiltUrl(url, { + type: 'asset', + hostType: 'js', + ssr: true, + hostId: '', + }) + + if (typeof result === 'object') { + if (result.runtime) { + return new RuntimeAsset(result.runtime) + } + assert( + !result.relative, + '"result.relative" not supported on renderBuiltUrl() for RSC', + ) + } else if (result) { + return result satisfies string + } + } + + // https://github.com/vitejs/vite/blob/2a7473cfed96237711cda9f736465c84d442ddef/packages/vite/src/node/plugins/importAnalysisBuild.ts#L222-L230 return config.base + url } function assetsURLOfDeps(deps: AssetDeps) { return { - js: deps.js.map((href) => assetsURL(href)), - css: deps.css.map((href) => assetsURL(href)), + js: deps.js.map((href) => { + assert(typeof href === 'string') + return assetsURL(href) + }), + css: deps.css.map((href) => { + assert(typeof href === 'string') + return assetsURL(href) + }), } } @@ -1290,12 +1355,23 @@ function assetsURLOfDeps(deps: AssetDeps) { // export type AssetsManifest = { - bootstrapScriptContent: string + bootstrapScriptContent: string | RuntimeAsset clientReferenceDeps: Record - serverResources?: Record + serverResources?: Record> } export type AssetDeps = { + js: (string | RuntimeAsset)[] + css: (string | RuntimeAsset)[] +} + +export type ResolvedAssetsManifest = { + bootstrapScriptContent: string + clientReferenceDeps: Record + serverResources?: Record> +} + +export type ResolvedAssetDeps = { js: string[] css: string[] } @@ -1574,7 +1650,7 @@ export function vitePluginRscCss( this.addWatchFile(file) } const hrefs = result.hrefs.map((href) => assetsURL(href.slice(1))) - return `export default ${JSON.stringify(hrefs)}` + return `export default ${serializeValueWithRuntime(hrefs)}` } }, }, @@ -1661,7 +1737,7 @@ export function vitePluginRscCss( encodeURIComponent(importer), ] const deps = assetsURLOfDeps({ css: cssHrefs, js: jsHrefs }) - return generateResourcesCode(JSON.stringify(deps, null, 2)) + return generateResourcesCode(serializeValueWithRuntime(deps)) } else { const key = normalizePath(path.relative(config.root, importer)) serverResourcesMetaMap[importer] = { key } @@ -1742,7 +1818,10 @@ function collectModuleDependents(mods: EnvironmentModuleNode[]) { } function generateResourcesCode(depsCode: string) { - const ResourcesFn = (React: typeof import('react'), deps: AssetDeps) => { + const ResourcesFn = ( + React: typeof import('react'), + deps: ResolvedAssetDeps, + ) => { return function Resources() { return React.createElement(React.Fragment, null, [ ...deps.css.map((href: string) => diff --git a/packages/plugin-rsc/src/ssr.tsx b/packages/plugin-rsc/src/ssr.tsx index ecd0b18fb..dec5e6c17 100644 --- a/packages/plugin-rsc/src/ssr.tsx +++ b/packages/plugin-rsc/src/ssr.tsx @@ -2,7 +2,7 @@ import assetsManifest from 'virtual:vite-rsc/assets-manifest' import * as clientReferences from 'virtual:vite-rsc/client-references' import * as ReactDOM from 'react-dom' import { setRequireModule } from './core/ssr' -import type { AssetDeps } from './plugin' +import type { ResolvedAssetDeps } from './plugin' export { createServerConsumerManifest } from './core/ssr' @@ -37,7 +37,7 @@ function initialize(): void { } // preload/preinit during getter access since `load` is cached on production -function wrapResourceProxy(mod: any, deps?: AssetDeps) { +function wrapResourceProxy(mod: any, deps?: ResolvedAssetDeps) { return new Proxy(mod, { get(target, p, receiver) { if (p in mod) { @@ -50,7 +50,7 @@ function wrapResourceProxy(mod: any, deps?: AssetDeps) { }) } -function preloadDeps(deps: AssetDeps) { +function preloadDeps(deps: ResolvedAssetDeps) { for (const href of deps.js) { ReactDOM.preloadModule(href, { as: 'script', diff --git a/packages/plugin-rsc/src/types/virtual.d.ts b/packages/plugin-rsc/src/types/virtual.d.ts index 81bbc242d..eb2108382 100644 --- a/packages/plugin-rsc/src/types/virtual.d.ts +++ b/packages/plugin-rsc/src/types/virtual.d.ts @@ -1,5 +1,5 @@ declare module 'virtual:vite-rsc/assets-manifest' { - const assetsManifest: import('../plugin').AssetsManifest + const assetsManifest: import('../plugin').ResolvedAssetsManifest export default assetsManifest }