diff --git a/.changeset/bridge-ssr-v1.md b/.changeset/bridge-ssr-v1.md new file mode 100644 index 00000000000..a03c0a77087 --- /dev/null +++ b/.changeset/bridge-ssr-v1.md @@ -0,0 +1,7 @@ +--- +'@module-federation/bridge-shared': minor +'@module-federation/bridge-react': minor +'@module-federation/bridge-vue3': minor +--- + +add a thin buffered Bridge SSR contract with remote-owned React 18 and Vue 3 rendering, document-slot hydration, identity-only host references, instance-scoped mount markers, and contextual server-render failures diff --git a/.github/workflows/e2e-bridge-ssr.yml b/.github/workflows/e2e-bridge-ssr.yml new file mode 100644 index 00000000000..da56fe37fad --- /dev/null +++ b/.github/workflows/e2e-bridge-ssr.yml @@ -0,0 +1,30 @@ +name: E2E Test for Bridge SSR + +on: + pull_request: + paths: + - 'packages/bridge/**' + - 'apps/bridge-ssr-demo/**' + - 'tools/scripts/*bridge-ssr*' + - '.github/workflows/e2e-bridge-ssr.yml' + - 'pnpm-lock.yaml' + +concurrency: + group: e2e-bridge-ssr-${{ github.ref }} + cancel-in-progress: true + +jobs: + rsbuild: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '24' + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run build:packages + - run: pnpm run e2e:bridge:ssr + - run: pnpm run e2e:bridge:ssr:production diff --git a/.prettierignore b/.prettierignore index a9e2bf4e1d4..eee1c0d6961 100644 --- a/.prettierignore +++ b/.prettierignore @@ -25,3 +25,6 @@ packages/assemble-release-plan/** # Generated Chrome DevTools proxy asset packages/chrome-devtools/src/vendor/basic-proxy-core.js + +**/playwright-report/** +**/test-results/** diff --git a/apps/bridge-ssr-demo/.gitignore b/apps/bridge-ssr-demo/.gitignore new file mode 100644 index 00000000000..dbd77de6c7e --- /dev/null +++ b/apps/bridge-ssr-demo/.gitignore @@ -0,0 +1 @@ +**/dist/ diff --git a/apps/bridge-ssr-demo/README.md b/apps/bridge-ssr-demo/README.md new file mode 100644 index 00000000000..80b134c5fdf --- /dev/null +++ b/apps/bridge-ssr-demo/README.md @@ -0,0 +1,17 @@ +# Bridge SSR V1 acceptance demo + +Rsbuild demo for application-level Bridge SSR in both directions: + +- React 18 host → Vue 3 remote +- Vue 3 host → React 18 remote + +Contract details: [Bridge SSR guide](../website-new/docs/en/guide/bridge/ssr.mdx). + +Run from the repository root: + +```bash +pnpm run e2e:bridge:ssr +pnpm run e2e:bridge:ssr:production +``` + +These suites cover no-JS HTML, single-copy transport, hydration, deep routes, navigation, CSR revisits after consumption, multiple instances, and browser-bundle exclusion of server-only renderers. diff --git a/apps/bridge-ssr-demo/host-vue/package.json b/apps/bridge-ssr-demo/host-vue/package.json new file mode 100644 index 00000000000..50dd19c626d --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/package.json @@ -0,0 +1,31 @@ +{ + "name": "bridge-ssr-host-vue", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "NODE_OPTIONS=--experimental-vm-modules node ./server.mjs", + "build": "rsbuild build", + "serve:production": "NODE_OPTIONS=--experimental-vm-modules BRIDGE_SSR_MODE=production node ./server.mjs", + "e2e:ssr:ci": "node ../../../scripts/ensure-playwright.js && playwright test --config playwright.config.ts --reporter=line" + }, + "dependencies": { + "@module-federation/bridge-react": "workspace:*", + "@module-federation/bridge-shared": "workspace:*", + "@module-federation/bridge-vue3": "workspace:*", + "@module-federation/enhanced": "workspace:*", + "@module-federation/runtime": "workspace:*", + "@vue/server-renderer": "^3.5.30", + "express": "^4.21.2", + "react": "18.3.1", + "react-dom": "18.3.1", + "vue": "^3.5.30", + "vue-router": "4.4.5" + }, + "devDependencies": { + "@module-federation/rsbuild-plugin": "workspace:*", + "@playwright/test": "1.57.0", + "@rsbuild/core": "1.7.3", + "@rsbuild/plugin-vue": "^1.2.6", + "typescript": "~5.0.4" + } +} diff --git a/apps/bridge-ssr-demo/host-vue/playwright.config.ts b/apps/bridge-ssr-demo/host-vue/playwright.config.ts new file mode 100644 index 00000000000..db2de20c99a --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: '../host/e2e', + timeout: 60_000, + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + workers: 1, + use: { + baseURL: process.env.BRIDGE_SSR_VUE_BASE_URL ?? 'http://localhost:2303', + trace: 'on-first-retry', + ...devices['Desktop Chrome'], + }, + projects: [ + { + name: 'vue-host-nojs', + testMatch: /bridge-ssr-nojs\.spec\.ts/, + use: { javaScriptEnabled: false }, + }, + { + name: 'vue-host-browser', + testMatch: /bridge-ssr-browser\.spec\.ts/, + }, + ], +}); diff --git a/apps/bridge-ssr-demo/host-vue/rsbuild.config.ts b/apps/bridge-ssr-demo/host-vue/rsbuild.config.ts new file mode 100644 index 00000000000..318329e9621 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/rsbuild.config.ts @@ -0,0 +1,49 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginVue } from '@rsbuild/plugin-vue'; +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { + bridgeSsrHostUrl, + bridgeSsrRemotePort, + bridgeSsrServerManifestPath, +} from '../shared/devHost'; + +const reactOrigin = bridgeSsrHostUrl(bridgeSsrRemotePort('react')); +const webRemotes = { + bridge_ssr_react: `bridge_ssr_react@${reactOrigin}/mf-manifest.json`, +}; +const ssrRemotes = { + bridge_ssr_react: `bridge_ssr_react@${reactOrigin}/${bridgeSsrServerManifestPath}`, +}; +const shared = { + vue: { singleton: true, eager: true }, + 'vue-router': { singleton: true, eager: true }, + react: { singleton: true, eager: true }, + 'react-dom': { singleton: true, eager: true }, +}; + +export default defineConfig({ + performance: { buildCache: false }, + server: { port: 2303, cors: true, middlewareMode: true }, + environments: { + client: { + source: { entry: { index: './src/entry.client.ts' } }, + html: { template: './src/index.html' }, + output: { distPath: { root: 'dist' } }, + }, + ssr: { + source: { entry: { index: './src/entry.server.ts' } }, + output: { target: 'node', distPath: { root: 'dist/ssr' } }, + }, + }, + plugins: [ + pluginVue(), + pluginModuleFederation( + { name: 'bridge_ssr_vue_host', remotes: webRemotes, shared }, + { environment: 'client' }, + ), + pluginModuleFederation( + { name: 'bridge_ssr_vue_host', remotes: ssrRemotes, shared }, + { target: 'node', environment: 'ssr' }, + ), + ], +}); diff --git a/apps/bridge-ssr-demo/host-vue/server.mjs b/apps/bridge-ssr-demo/host-vue/server.mjs new file mode 100644 index 00000000000..69bf42e65b0 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/server.mjs @@ -0,0 +1,11 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startBridgeHost } from '../shared/createHostServer.mjs'; + +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +startBridgeHost({ rootDir, port: 2303, label: 'bridge-ssr-vue-host' }).catch( + (error) => { + console.error('[bridge-ssr-vue-host] failed:', error); + process.exitCode = 1; + }, +); diff --git a/apps/bridge-ssr-demo/host-vue/src/App.vue b/apps/bridge-ssr-demo/host-vue/src/App.vue new file mode 100644 index 00000000000..7df3e1cc223 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/App.vue @@ -0,0 +1,76 @@ + + + diff --git a/apps/bridge-ssr-demo/host-vue/src/entry.client.ts b/apps/bridge-ssr-demo/host-vue/src/entry.client.ts new file mode 100644 index 00000000000..3d95da30f37 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/entry.client.ts @@ -0,0 +1,16 @@ +import { createSSRApp } from 'vue'; +import { createRouter, createWebHistory } from 'vue-router'; +import { createBridgeHydrationRegistry } from '@module-federation/bridge-shared'; +import { provideBridgeHydrationRegistry } from '@module-federation/bridge-vue3'; +import App from './App.vue'; +import { readHostSSRContext } from './ssrContext'; + +const router = createRouter({ + history: createWebHistory(), + routes: [{ path: '/:pathMatch(.*)*', component: App }], +}); +const app = createSSRApp(App, { ssrContext: readHostSSRContext() }); +app.use(router); +provideBridgeHydrationRegistry(app, createBridgeHydrationRegistry(document)); +await router.isReady(); +app.mount('#root'); diff --git a/apps/bridge-ssr-demo/host-vue/src/entry.server.ts b/apps/bridge-ssr-demo/host-vue/src/entry.server.ts new file mode 100644 index 00000000000..818631b00e9 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/entry.server.ts @@ -0,0 +1,34 @@ +import { createSSRApp } from 'vue'; +import { renderToString } from '@vue/server-renderer'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import App from './App.vue'; +import { toHostHydrationContext } from './ssrContext'; +import { + prepareSSRContext, + type PrepareSSRContextOptions, +} from './prepareSSRContext'; + +async function createHostApp(url: string, options: PrepareSSRContextOptions) { + const ssrContext = await prepareSSRContext(url, options); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/:pathMatch(.*)*', component: App }], + }); + await router.push(url); + await router.isReady(); + const app = createSSRApp(App, { ssrContext }); + app.use(router); + return { app, ssrContext }; +} + +export async function render( + url: string, + options: PrepareSSRContextOptions = {}, +) { + const { app, ssrContext } = await createHostApp(url, options); + return { + html: await renderToString(app), + ssrContext, + hydrationContext: toHostHydrationContext(ssrContext), + }; +} diff --git a/apps/bridge-ssr-demo/host-vue/src/env.d.ts b/apps/bridge-ssr-demo/host-vue/src/env.d.ts new file mode 100644 index 00000000000..c736914276c --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/env.d.ts @@ -0,0 +1,4 @@ +/// + +declare module 'bridge_ssr_react/export-app'; +declare module 'bridge_ssr_vue/export-app'; diff --git a/apps/bridge-ssr-demo/host-vue/src/index.html b/apps/bridge-ssr-demo/host-vue/src/index.html new file mode 100644 index 00000000000..edf442c4adf --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/index.html @@ -0,0 +1,11 @@ + + + + + + Bridge SSR Vue Host + + +
+ + diff --git a/apps/bridge-ssr-demo/host-vue/src/prepareSSRContext.ts b/apps/bridge-ssr-demo/host-vue/src/prepareSSRContext.ts new file mode 100644 index 00000000000..97af22acd50 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/prepareSSRContext.ts @@ -0,0 +1,51 @@ +import { getInstance } from '@module-federation/runtime'; +import { renderRemoteBridge } from '@module-federation/bridge-shared'; +import { normalizeBridgeRequestUrl } from '../../host/src/lib/normalizeRequestUrl.mjs'; +import type { HostSSRContext } from './ssrContext'; + +export type PrepareSSRContextOptions = { signal?: AbortSignal }; + +export async function prepareSSRContext( + url: string, + options: PrepareSSRContextOptions = {}, +): Promise { + const requestUrl = normalizeBridgeRequestUrl(url); + const parsed = new URL(requestUrl, 'http://bridge-ssr.local'); + const context: HostSSRContext = { url: requestUrl }; + const instance = getInstance(); + if (!instance) + throw new Error('Module Federation runtime is not initialized'); + const request = new Request(parsed, { signal: options.signal }); + + const renderReact = ( + instanceId: string, + basename: string, + name: string, + age: number, + ) => + renderRemoteBridge({ + loader: () => + instance.loadRemote>( + 'bridge_ssr_react/export-app', + ), + moduleName: 'bridge_ssr_react', + instanceId, + request, + props: { basename, name, age }, + }); + + if (parsed.pathname.startsWith('/react-pair')) { + context.reactPair = await Promise.all([ + renderReact('react-pair-left', '/react-pair', 'Left', 1), + renderReact('react-pair-right', '/react-pair', 'Right', 2), + ]); + } else if (parsed.pathname.startsWith('/react-remote')) { + context.reactRemote = await renderReact( + 'react-primary', + '/react-remote', + 'Ming', + 12, + ); + } + return context; +} diff --git a/apps/bridge-ssr-demo/host-vue/src/remoteApps.ts b/apps/bridge-ssr-demo/host-vue/src/remoteApps.ts new file mode 100644 index 00000000000..0ffa9858501 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/remoteApps.ts @@ -0,0 +1,5 @@ +import { createRemoteAppComponent } from '@module-federation/bridge-vue3'; + +export const RemoteReactApp = createRemoteAppComponent({ + loader: () => import('bridge_ssr_react/export-app'), +}); diff --git a/apps/bridge-ssr-demo/host-vue/src/ssrContext.ts b/apps/bridge-ssr-demo/host-vue/src/ssrContext.ts new file mode 100644 index 00000000000..b6a605f44d7 --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/src/ssrContext.ts @@ -0,0 +1,57 @@ +import { + assertBridgeSSRReference, + toBridgeSSRReference, + type BridgeSSRReference, + type BridgeSSRResult, +} from '@module-federation/bridge-shared'; + +export type RemoteSSRData = BridgeSSRResult | BridgeSSRReference; + +export type HostSSRContext = { + url: string; + reactRemote?: RemoteSSRData; + reactPair?: [RemoteSSRData, RemoteSSRData]; +}; + +export type HostHydrationContext = { + url: string; + reactRemote?: BridgeSSRReference; + reactPair?: [BridgeSSRReference, BridgeSSRReference]; +}; + +function toReference(data: RemoteSSRData): BridgeSSRReference { + if ('html' in data) return toBridgeSSRReference(data); + assertBridgeSSRReference(data); + return data; +} + +export function toHostHydrationContext( + context: HostSSRContext, +): HostHydrationContext { + return { + url: context.url, + ...(context.reactRemote + ? { reactRemote: toReference(context.reactRemote) } + : {}), + ...(context.reactPair + ? { + reactPair: context.reactPair.map(toReference) as [ + BridgeSSRReference, + BridgeSSRReference, + ], + } + : {}), + }; +} + +export const HOST_SSR_CONTEXT_SCRIPT_ID = 'bridge-ssr-host-context'; + +export function readHostSSRContext() { + const script = document.getElementById(HOST_SSR_CONTEXT_SCRIPT_ID); + if (!script?.textContent) return undefined; + try { + return JSON.parse(script.textContent) as HostHydrationContext; + } catch { + return undefined; + } +} diff --git a/apps/bridge-ssr-demo/host-vue/tsconfig.json b/apps/bridge-ssr-demo/host-vue/tsconfig.json new file mode 100644 index 00000000000..8a26c51abfb --- /dev/null +++ b/apps/bridge-ssr-demo/host-vue/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "types": ["node"] + }, + "include": ["src", "rsbuild.config.ts", "playwright.config.ts"] +} diff --git a/apps/bridge-ssr-demo/host/e2e/bridge-ssr-browser.spec.ts b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-browser.spec.ts new file mode 100644 index 00000000000..be739f4e1e5 --- /dev/null +++ b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-browser.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from './fixtures'; + +const direction = (projectName: string) => + projectName.startsWith('vue-host') + ? { + route: '/react-remote', + detail: '/react-remote/detail', + homeHeading: 'React Remote Home', + detailHeading: 'React Remote Detail', + counter: '.react-remote-counter', + detailLink: '.react-remote-detail-link', + hostLink: '.host-react-remote-link', + } + : { + route: '/vue-remote', + detail: '/vue-remote/detail', + homeHeading: 'Vue Remote Home', + detailHeading: 'Vue Remote Detail', + counter: '.vue-remote-counter', + detailLink: '.vue-remote-detail-link', + hostLink: '.host-vue-remote-link', + }; + +test('hydrates the direct SSR island and remains interactive', async ({ + page, +}, testInfo) => { + const remote = direction(testInfo.project.name); + await page.goto(remote.route); + await expect( + page.getByRole('heading', { name: remote.homeHeading }), + ).toBeVisible(); + const mount = page.locator('[data-mf-bridge-ssr="true"]'); + await expect(mount).toHaveCount(1); + const counter = page.locator(remote.counter); + await counter.click(); + await expect(counter).toHaveText(/Count: 1/); +}); + +test('server-renders a deep route and remote navigation updates the URL', async ({ + page, +}, testInfo) => { + const remote = direction(testInfo.project.name); + await page.goto(`${remote.detail}?source=e2e#bridge-state`); + await expect(page).toHaveURL( + new RegExp(`${remote.detail}\\?source=e2e#bridge-state$`), + ); + await expect( + page.getByRole('heading', { name: remote.detailHeading }), + ).toBeVisible(); + await page.goto(remote.route); + await page.locator(remote.detailLink).click(); + await expect(page).toHaveURL(new RegExp(`${remote.detail}$`)); + await expect( + page.getByRole('heading', { name: remote.detailHeading }), + ).toBeVisible(); +}); + +test('host navigation enters, leaves, and returns through CSR without reload', async ({ + page, +}, testInfo) => { + const remote = direction(testInfo.project.name); + await page.goto('/'); + await page.evaluate(() => { + ( + window as Window & { __navigationSentinel?: string } + ).__navigationSentinel = 'alive'; + }); + await page.locator(remote.hostLink).first().click(); + await expect( + page.getByRole('heading', { name: remote.homeHeading }), + ).toBeVisible(); + await page.getByRole('link', { name: 'Home' }).first().click(); + await expect(page).toHaveURL(/\/$/); + await page.locator(remote.hostLink).first().click(); + await expect( + page.getByRole('heading', { name: remote.homeHeading }), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => (window as any).__navigationSentinel)) + .toBe('alive'); +}); + +test('consumes direct-visit SSR once and revisits through CSR', async ({ + page, +}, testInfo) => { + const remote = direction(testInfo.project.name); + await page.goto(remote.route); + await expect(page.locator('[data-mf-bridge-ssr="true"]')).toHaveCount(1); + await expect( + page.getByRole('heading', { name: remote.homeHeading }), + ).toBeVisible(); + + await page.getByRole('link', { name: 'Home' }).first().click(); + await page.locator(remote.hostLink).first().click(); + await expect( + page.getByRole('heading', { name: remote.homeHeading }), + ).toBeVisible(); + await expect(page.locator('[data-mf-bridge-ssr="true"]')).toHaveCount(0); +}); + +test('two instances of one React remote hydrate independently', async ({ + page, +}, testInfo) => { + test.skip(!testInfo.project.name.startsWith('vue-host')); + await page.goto('/react-pair'); + const mounts = page.locator('[data-mf-bridge-ssr="true"]'); + await expect(mounts).toHaveCount(2); + const ids = await mounts.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute('data-mf-bridge-instance')), + ); + expect(new Set(ids).size).toBe(2); + const counters = page.locator('.react-remote-counter'); + await counters.nth(0).click(); + await expect(counters.nth(0)).toHaveText('Count: 1'); + await expect(counters.nth(1)).toHaveText('Count: 0'); + await expect(page.getByText('name: Left, age: 1')).toBeVisible(); + await expect(page.getByText('name: Right, age: 2')).toBeVisible(); +}); diff --git a/apps/bridge-ssr-demo/host/e2e/bridge-ssr-nojs.spec.ts b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-nojs.spec.ts new file mode 100644 index 00000000000..db31282b8c9 --- /dev/null +++ b/apps/bridge-ssr-demo/host/e2e/bridge-ssr-nojs.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +test('raw response contains the cross-framework remote route', async ({ + page, + request, +}, testInfo) => { + const vueHost = testInfo.project.name.startsWith('vue-host'); + const route = vueHost ? '/react-remote/detail' : '/vue-remote/detail'; + const heading = vueHost ? 'React Remote Detail' : 'Vue Remote Detail'; + const response = await request.get(route); + const html = await response.text(); + expect(response.ok()).toBeTruthy(); + expect(html).toContain(heading); + expect(html).toContain('data-mf-bridge-ssr="true"'); + expect(html).toContain('data-mf-bridge-slot="true"'); + expect(html).toContain('data-mf-bridge-state="true"'); + expect(html).toContain('bridge-ssr-host-context'); + expect(html.match(new RegExp(heading, 'g'))).toHaveLength(1); + + const contextMatch = html.match( + / diff --git a/apps/bridge-ssr-demo/remote-vue/src/export-app.server.ts b/apps/bridge-ssr-demo/remote-vue/src/export-app.server.ts new file mode 100644 index 00000000000..c90e02a653d --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/src/export-app.server.ts @@ -0,0 +1,32 @@ +import App from './RemoteVueApplication.vue'; +import { createBridgeComponent } from '@module-federation/bridge-vue3'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import Home from './pages/Home.vue'; +import Detail from './pages/Detail.vue'; + +export default createBridgeComponent({ + rootComponent: App, + appOptions: () => ({ + router: createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Home }, + { path: '/detail', component: Detail }, + ], + }), + }), + ssr: { + prepare(context) { + const props = context.props as { test?: string; basename?: string }; + return { + props: { + ...(typeof props.test === 'string' ? { test: props.test } : {}), + ...(typeof props.basename === 'string' + ? { basename: props.basename } + : {}), + }, + dehydratedState: { test: props.test ?? null }, + }; + }, + }, +}); diff --git a/apps/bridge-ssr-demo/remote-vue/src/export-app.ts b/apps/bridge-ssr-demo/remote-vue/src/export-app.ts new file mode 100644 index 00000000000..fcb14c40b0c --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/src/export-app.ts @@ -0,0 +1,26 @@ +import App from './RemoteVueApplication.vue'; +import { createBridgeComponent } from '@module-federation/bridge-vue3'; +import { createRouter, createWebHistory } from 'vue-router'; +import Home from './pages/Home.vue'; +import Detail from './pages/Detail.vue'; + +export default createBridgeComponent({ + rootComponent: App, + appOptions: () => ({ + router: createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: Home }, + { path: '/detail', component: Detail }, + ], + }), + }), + ssr: { + hydrate(state) { + if (!state || Array.isArray(state) || typeof state !== 'object') + return {}; + const test = (state as Record).test; + return typeof test === 'string' ? { test } : {}; + }, + }, +}); diff --git a/apps/bridge-ssr-demo/remote-vue/src/index.html b/apps/bridge-ssr-demo/remote-vue/src/index.html new file mode 100644 index 00000000000..c7fe0adc4c3 --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/src/index.html @@ -0,0 +1,11 @@ + + + + + + Bridge SSR Vue Remote + + +
+ + diff --git a/apps/bridge-ssr-demo/remote-vue/src/pages/Detail.vue b/apps/bridge-ssr-demo/remote-vue/src/pages/Detail.vue new file mode 100644 index 00000000000..2e05e520e52 --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/src/pages/Detail.vue @@ -0,0 +1,6 @@ + diff --git a/apps/bridge-ssr-demo/remote-vue/src/pages/Home.vue b/apps/bridge-ssr-demo/remote-vue/src/pages/Home.vue new file mode 100644 index 00000000000..8373d64d00d --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/src/pages/Home.vue @@ -0,0 +1,16 @@ + + + diff --git a/apps/bridge-ssr-demo/remote-vue/src/ssr-env.ts b/apps/bridge-ssr-demo/remote-vue/src/ssr-env.ts new file mode 100644 index 00000000000..ffee3d7483a --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/src/ssr-env.ts @@ -0,0 +1,6 @@ +/** + * Node MF environment entry for the Vue remote. + * Rsbuild builds this to dist/ssr/ for the host to consume via + * bridge_ssr_vue@/ssr/mf-manifest.json at request time. + */ +export {}; diff --git a/apps/bridge-ssr-demo/remote-vue/src/standalone.ts b/apps/bridge-ssr-demo/remote-vue/src/standalone.ts new file mode 100644 index 00000000000..4f6adaaeb2d --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/src/standalone.ts @@ -0,0 +1,15 @@ +import { createApp } from 'vue'; +import App from './RemoteVueApplication.vue'; +import { createRouter, createWebHistory } from 'vue-router'; +import Home from './pages/Home.vue'; +import Detail from './pages/Detail.vue'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: Home }, + { path: '/detail', component: Detail }, + ], +}); + +createApp(App).use(router).mount('#root'); diff --git a/apps/bridge-ssr-demo/remote-vue/tsconfig.json b/apps/bridge-ssr-demo/remote-vue/tsconfig.json new file mode 100644 index 00000000000..ac97d6272cc --- /dev/null +++ b/apps/bridge-ssr-demo/remote-vue/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "rsbuild.config.ts"] +} diff --git a/apps/bridge-ssr-demo/shared/createHostServer.mjs b/apps/bridge-ssr-demo/shared/createHostServer.mjs new file mode 100644 index 00000000000..cb81f843efb --- /dev/null +++ b/apps/bridge-ssr-demo/shared/createHostServer.mjs @@ -0,0 +1,108 @@ +import express from 'express'; +import { createRsbuild, loadConfig } from '@rsbuild/core'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { serializeBridgeJSON } from '@module-federation/bridge-shared/dist/index.es.js'; + +const shouldRender = (url) => { + if (url.startsWith('/@') || url.startsWith('/__rsbuild')) return false; + const pathname = url.split('?')[0]; + return !pathname.includes('.') || pathname.endsWith('.html'); +}; + +function assembleDocument(template, html, ssrContext) { + const marker = ''; + if (!template.includes(marker)) { + throw new Error(`Bridge SSR host template is missing ${marker}`); + } + const payload = ``; + return template.replace(marker, html).replace('', `${payload}`); +} + +function registerSSRRoute(app, getBundle, getTemplate, label) { + app.get('*', async (req, res, next) => { + if (!shouldRender(req.originalUrl)) return next(); + const controller = new AbortController(); + const disconnect = () => controller.abort(new Error('Client disconnected')); + req.once('aborted', disconnect); + try { + const [bundle, template] = await Promise.all([ + getBundle(), + getTemplate(), + ]); + const rendered = await bundle.render(req.originalUrl, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + res + .status(200) + .type('html') + .set('Cache-Control', 'no-store') + .send( + assembleDocument(template, rendered.html, rendered.hydrationContext), + ); + } catch (error) { + if (!controller.signal.aborted) { + console.error(`[${label}] SSR request failed`, error, error?.cause); + next(error); + } + } finally { + req.off('aborted', disconnect); + } + }); +} + +export async function startBridgeHost({ rootDir, port, label }) { + const host = + process.env.HOST ?? process.env.BRIDGE_SSR_DEV_HOST ?? 'localhost'; + const app = express(); + if (process.env.BRIDGE_SSR_MODE === 'production') { + const dist = path.join(rootDir, 'dist'); + const template = await readFile(path.join(dist, 'index.html'), 'utf8'); + const imported = await import( + `${pathToFileURL(path.join(dist, 'ssr', 'index.js')).href}?${Date.now()}` + ); + registerSSRRoute( + app, + async () => imported.default ?? imported, + async () => template, + label, + ); + app.use(express.static(dist)); + } else { + const { content } = await loadConfig({ cwd: rootDir }); + const rsbuild = await createRsbuild({ + cwd: rootDir, + rsbuildConfig: content, + }); + const server = await rsbuild.createDevServer(); + let bundlePromise; + const getBundle = () => { + bundlePromise ??= server.environments.ssr + .loadBundle('index') + .catch((error) => { + bundlePromise = undefined; + throw error; + }); + return bundlePromise; + }; + registerSSRRoute( + app, + getBundle, + () => server.environments.client.getTransformedHtml('index'), + label, + ); + app.use(server.middlewares); + const httpServer = app.listen(port, host, async () => { + await server.afterListen(); + console.log(`[${label}] ready at http://${host}:${port}`); + }); + server.connectWebSocket({ server: httpServer }); + return; + } + + app.listen(port, host, () => { + console.log(`[${label}] production ready at http://${host}:${port}`); + }); +} diff --git a/apps/bridge-ssr-demo/shared/devHost.ts b/apps/bridge-ssr-demo/shared/devHost.ts new file mode 100644 index 00000000000..b15ab803537 --- /dev/null +++ b/apps/bridge-ssr-demo/shared/devHost.ts @@ -0,0 +1,10 @@ +export const BRIDGE_SSR_DEV_HOST = + process.env.BRIDGE_SSR_DEV_HOST ?? 'localhost'; + +export const bridgeSsrRemotePort = (framework: 'react' | 'vue') => + framework === 'react' ? 2301 : 2302; + +export const bridgeSsrServerManifestPath = 'ssr/mf-manifest.json'; + +export const bridgeSsrHostUrl = (port: number) => + `http://${BRIDGE_SSR_DEV_HOST}:${port}`; diff --git a/apps/bridge-ssr-demo/shared/package.json b/apps/bridge-ssr-demo/shared/package.json new file mode 100644 index 00000000000..99cc4bbc4db --- /dev/null +++ b/apps/bridge-ssr-demo/shared/package.json @@ -0,0 +1,11 @@ +{ + "name": "bridge-ssr-demo-shared", + "private": true, + "version": "0.0.1", + "type": "module", + "dependencies": { + "@module-federation/bridge-shared": "workspace:*", + "@rsbuild/core": "1.7.3", + "express": "^4.21.2" + } +} diff --git a/apps/bridge-ssr-demo/shared/pluginServeSsrDist.ts b/apps/bridge-ssr-demo/shared/pluginServeSsrDist.ts new file mode 100644 index 00000000000..fa6c1f40146 --- /dev/null +++ b/apps/bridge-ssr-demo/shared/pluginServeSsrDist.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { RsbuildPlugin } from '@rsbuild/core'; + +function getContentType(filePath: string) { + if (filePath.endsWith('.json')) { + return 'application/json'; + } + if (filePath.endsWith('.js')) { + return 'application/javascript'; + } + if (filePath.endsWith('.map')) { + return 'application/json'; + } + return 'application/octet-stream'; +} + +export function pluginServeSsrDist(rootDir = process.cwd()): RsbuildPlugin { + const ssrDir = path.join(rootDir, 'dist', 'ssr'); + + return { + name: 'plugin-serve-ssr-dist', + setup(api) { + api.modifyRsbuildConfig((config) => { + config.dev ??= {}; + const previous = config.dev.setupMiddlewares; + + config.dev.setupMiddlewares = (middlewares, server) => { + if (typeof previous === 'function') { + previous(middlewares, server); + } else if (Array.isArray(previous)) { + previous.forEach((setup) => setup(middlewares, server)); + } + + middlewares.unshift((req, res, next) => { + const url = req.url?.split('?')[0] ?? ''; + if (!url.startsWith('/ssr/')) { + next(); + return; + } + + const relativePath = decodeURIComponent(url.slice('/ssr/'.length)); + const filePath = path.join(ssrDir, relativePath); + + if ( + !filePath.startsWith(ssrDir) || + !fs.existsSync(filePath) || + fs.statSync(filePath).isDirectory() + ) { + next(); + return; + } + + res.statusCode = 200; + res.setHeader('Content-Type', getContentType(filePath)); + res.setHeader('Access-Control-Allow-Origin', '*'); + fs.createReadStream(filePath).pipe(res); + }); + }; + }); + }, + }; +} diff --git a/apps/bridge-ssr-demo/shared/routerAlias.ts b/apps/bridge-ssr-demo/shared/routerAlias.ts new file mode 100644 index 00000000000..6ce885d8c98 --- /dev/null +++ b/apps/bridge-ssr-demo/shared/routerAlias.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const directory = path.dirname(fileURLToPath(import.meta.url)); + +export function createBridgeRouterAlias(reactRouterDomPath: string) { + return { + 'react-router-dom$': path.resolve( + directory, + '../../../packages/bridge/bridge-react/dist/router-v6.es.js', + ), + 'react-router-dom/dist/index.js': reactRouterDomPath, + }; +} diff --git a/apps/bridge-ssr-demo/shared/serveDist.mjs b/apps/bridge-ssr-demo/shared/serveDist.mjs new file mode 100644 index 00000000000..292ec4f14fa --- /dev/null +++ b/apps/bridge-ssr-demo/shared/serveDist.mjs @@ -0,0 +1,19 @@ +import express from 'express'; +import path from 'node:path'; + +const [distDir, portValue] = process.argv.slice(2); +const port = Number(portValue); + +if (!distDir || !Number.isInteger(port)) { + throw new Error('Usage: serveDist.mjs '); +} + +const app = express(); +app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + next(); +}); +app.use(express.static(path.resolve(distDir))); +app.listen(port, process.env.HOST ?? 'localhost', () => { + console.log(`[bridge-ssr-static] serving ${distDir} on port ${port}`); +}); diff --git a/apps/website-new/docs/en/guide/bridge/_meta.json b/apps/website-new/docs/en/guide/bridge/_meta.json index b7f300763b2..9703deaca9e 100644 --- a/apps/website-new/docs/en/guide/bridge/_meta.json +++ b/apps/website-new/docs/en/guide/bridge/_meta.json @@ -10,5 +10,10 @@ "label": "React", "collapsible": true, "collapsed": true + }, + { + "type": "file", + "name": "ssr", + "label": "Server rendering" } ] diff --git a/apps/website-new/docs/en/guide/bridge/ssr.mdx b/apps/website-new/docs/en/guide/bridge/ssr.mdx new file mode 100644 index 00000000000..a9d5c515922 --- /dev/null +++ b/apps/website-new/docs/en/guide/bridge/ssr.mdx @@ -0,0 +1,84 @@ +# Server rendering Bridge applications + +Bridge SSR V1 lets a federated application provider return a buffered HTML island on the server and hydrate that same island through the existing client lifecycle. + +## Supported scope + +- React 18 with declarative React Router v6 +- Vue 3 with Vue Router 4 +- Full server-side results and identity-only client hydration references +- Multiple instances of the same remote through document-unique instance IDs + +React data routers and other React or Router versions keep their existing client-rendering support but are not supported for SSR in V1. + +React Router v6 remotes must alias `react-router-dom$` to `@module-federation/bridge-react/dist/router-v6.es.js` in both their browser and Node builds. The default `router.es.js` adapter is CSR-only. Attempting to use the default adapter or `RouterProvider` during SSR throws a configuration error. + +## Remote provider + +Set `ssr: true` when no request preparation is needed. If preparation is required, map the same federated export key to separate browser and Node source files. The browser file supplies `hydrate`; the Node file supplies `prepare`. This keeps server renderers and request-only code out of browser chunks. + +```ts +// export-app.server.ts +export default createBridgeComponent({ + rootComponent: App, + ssr: { + prepare({ props }) { + return { + props, + dehydratedState: { initialValue: props.initialValue }, + }; + }, + }, +}); +``` + +The React Node entry owns `react-dom/server`; the Vue Node entry owns `@vue/server-renderer`. Framework elements, routers, application instances, and renderers never cross `@module-federation/bridge-shared`. + +## Host rendering + +Use `renderRemoteBridge` with an intentional, sanitized standard `Request`, stable module name, document-unique instance ID, and explicit props: + +```ts +const result = await renderRemoteBridge({ + loader: () => federationInstance.loadRemote('catalog/export-app'), + moduleName: 'catalog', + instanceId: 'catalog-sidebar', + request, + props: { basename: '/catalog' }, +}); +``` + +Pass the complete result to `createRemoteAppComponent` as `ssr` during server rendering. The adapter emits one instance-scoped slot containing exactly one marked mount child with the remote HTML and one adjacent `application/json` state child. The state child contains the versioned identity and optional dehydrated state, serialized with `serializeBridgeJSON` so executable HTML sequences are escaped. + +Only serialize an identity reference into the host hydration context: + +```ts +import { toBridgeSSRReference } from '@module-federation/bridge-shared'; + +const hydrationContext = { + catalog: toBridgeSSRReference(result), +}; +``` + +On the client, create the registry from the server-rendered document and install it before React `hydrateRoot` or the Vue host application mount. Pass the identity reference, not the full result, as `ssr`. + +```tsx +const registry = createBridgeHydrationRegistry(document); + +hydrateRoot( + document.getElementById('root')!, + + + , +); +``` + +Vue hosts call `provideBridgeHydrationRegistry(app, registry)` before `app.mount(...)`. A referenced slot is single-use: successful hydration consumes its snapshot, so navigating away and returning through the SPA takes the normal CSR path instead of rehydrating stale markup. A missing referenced slot also takes the CSR path. Malformed, duplicated, or identity-conflicting slots throw a contextual hydration error instead of silently mounting over invalid server markup. + +`renderRemoteBridge` rejects provider-load, unsupported-contract, identity, and rendering failures with contextual errors. Response policy remains host-owned. + +Bridge SSR V1 does not provide a shared load/render timeout or CSR fallback for server failures. A slow or broken remote can delay or fail the host response; hosts that need another policy must apply it around `renderRemoteBridge`. + +## Not included in V1 + +Assets/CSP/head management, status and header conflict policy, shared timeouts and server fallbacks, React data routers, additional React/Router versions, Nuxt or Modern.js adapters, portals/Teleports, streaming, backpressure, and benchmarks are follow-up work. diff --git a/package.json b/package.json index 31ffc62b07a..69cdbac4353 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "e2e:treeshake:frontend": "pnpm exec turbo run e2e --filter=@module-federation/treeshake-frontend", "e2e:modern:ssr": "node tools/scripts/run-modern-e2e.mjs --mode=manifest", "e2e:router": "node tools/scripts/run-router-e2e.mjs --mode=dev", + "e2e:bridge:ssr": "node tools/scripts/run-bridge-ssr-e2e.mjs", + "e2e:bridge:ssr:production": "BRIDGE_SSR_MODE=production node tools/scripts/run-bridge-ssr-e2e.mjs", "e2e:shared-tree-shaking:runtime-infer": "npx kill-port --port 3001,3002 && pnpm exec turbo run test:e2e --filter=shared-tree-shaking-no-server-host && lsof -ti tcp:3001,3002 | xargs kill", "e2e:shared-tree-shaking:server-calc": "npx kill-port --port 3001,3002,3003 && pnpm exec turbo run test:e2e --filter=shared-tree-shaking-with-server-host && lsof -ti tcp:3001,3002,3003 | xargs kill", "e2e:devtools:dev": "npx kill-port 3009 3010 3011 3012 3013 4001 && pnpm run app:manifest:dev & echo \"done\" && npx wait-on tcp:3009 tcp:3010 tcp:3011 tcp:3012 tcp:3013 && sleep 10 && pnpm exec turbo run test:e2e --filter=@module-federation/devtools", diff --git a/packages/bridge/bridge-react/README.md b/packages/bridge/bridge-react/README.md index ee86f2bddea..009a4a1cf49 100644 --- a/packages/bridge/bridge-react/README.md +++ b/packages/bridge/bridge-react/README.md @@ -74,6 +74,27 @@ export default defineConfig({ }); ``` +For Bridge SSR V1 with declarative React Router v6, both the browser and Node +builds must alias `react-router-dom$` to the version-specific adapter instead: + +```js +alias: { + 'react-router-dom$': path.resolve( + __dirname, + 'node_modules/@module-federation/bridge-react/dist/router-v6.es.js', + ), +} +``` + +The default `router.es.js` adapter remains the multi-version CSR adapter. React +data routers and `RouterProvider` are not supported for Bridge SSR V1. + +For hydration, serialize only `toBridgeSSRReference(result)` in host JSON. Create +`createBridgeHydrationRegistry(document)` and wrap the host with +`BridgeHydrationProvider` before calling `hydrateRoot`. The full server result is +rendered once into its document slot; the client reference consumes that slot's +validated snapshot once. Missing slots use CSR, while malformed slots throw. + # 3. Load the module with routing ```js diff --git a/packages/bridge/bridge-react/__tests__/router.spec.tsx b/packages/bridge/bridge-react/__tests__/router.spec.tsx index 3892a863965..95f1cbfd0f0 100644 --- a/packages/bridge/bridge-react/__tests__/router.spec.tsx +++ b/packages/bridge/bridge-react/__tests__/router.spec.tsx @@ -13,6 +13,18 @@ import { RouterContext } from '../src/provider/context'; import { getHtml, getWindowImpl } from './util'; describe('react router proxy', () => { + it('fails clearly when the generic router adapter is used for SSR', () => { + expect(() => + render( + + +
+ + , + ), + ).toThrow(/router-v6 alias/); + }); + it('BrowserRouter not wraper context', async () => { let { container } = render( diff --git a/packages/bridge/bridge-react/package.json b/packages/bridge/bridge-react/package.json index 99f973a9b48..0ab31d075ec 100644 --- a/packages/bridge/bridge-react/package.json +++ b/packages/bridge/bridge-react/package.json @@ -32,6 +32,14 @@ }, "./v18": { "types": "./dist/v18.d.ts", + "browser": { + "import": "./dist/v18.es.js", + "require": "./dist/v18.cjs.js" + }, + "node": { + "import": "./dist/v18.server.es.js", + "require": "./dist/v18.server.cjs.js" + }, "import": "./dist/v18.es.js", "require": "./dist/v18.cjs.js" }, @@ -189,6 +197,7 @@ "vite": "^5.4.21", "@module-federation/runtime": "workspace:*", "@module-federation/runtime-core": "workspace:*", - "hono": "4.12.34" + "hono": "4.12.34", + "jsdom": "20.0.3" } } diff --git a/packages/bridge/bridge-react/rstest.config.ts b/packages/bridge/bridge-react/rstest.config.ts index 1faeba420d4..31ea5beea0f 100644 --- a/packages/bridge/bridge-react/rstest.config.ts +++ b/packages/bridge/bridge-react/rstest.config.ts @@ -17,10 +17,7 @@ export default defineConfig({ extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'], }, testEnvironment: 'jsdom', - include: [ - path.resolve(__dirname, 'src/**/*.spec.ts'), - path.resolve(__dirname, 'src/**/*.spec.tsx'), - ], + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], globals: true, testTimeout: 10000, setupFiles: [path.resolve(__dirname, '__tests__/setupTests.ts')], diff --git a/packages/bridge/bridge-react/src/hydration.spec.tsx b/packages/bridge/bridge-react/src/hydration.spec.tsx new file mode 100644 index 00000000000..d12b97a35a4 --- /dev/null +++ b/packages/bridge/bridge-react/src/hydration.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@rstest/core'; +import { + createBridgeHydrationRegistry, + toBridgeSSRReference, +} from '@module-federation/bridge-shared'; +import { BridgeRemoteSlot } from './hydration'; + +const result = { + protocolVersion: 1 as const, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

server remote

', + dehydratedState: { ready: true }, +}; + +function installSlot() { + document.body.innerHTML = renderToStaticMarkup( + , + ); +} + +describe('Bridge hydration registry', () => { + it('recovers and consumes one immutable instance snapshot', () => { + installSlot(); + const registry = createBridgeHydrationRegistry(document); + const snapshot = registry.peek('remote/app', 'remote-1'); + + expect(snapshot).toEqual({ + protocolVersion: 1, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

server remote

', + state: { ready: true }, + }); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot?.state)).toBe(true); + expect(registry.consume('remote/app', 'remote-1')).toBe(snapshot); + expect(registry.peek('remote/app', 'remote-1')).toBeUndefined(); + }); + + it('renders a client reference from the recovered DOM without another payload copy', () => { + installSlot(); + const registry = createBridgeHydrationRegistry(document); + const snapshot = registry.peek('remote/app', 'remote-1'); + const reference = toBridgeSSRReference(result); + const clientMarkup = renderToStaticMarkup( + , + ); + + expect(clientMarkup).toBe(document.body.innerHTML); + expect(JSON.stringify(reference)).not.toContain('server remote'); + }); + + it('treats an absent slot as missing SSR data', () => { + document.body.innerHTML = ''; + expect( + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toBeUndefined(); + }); + + it('rejects malformed, mismatched, and duplicate slots', () => { + installSlot(); + const original = document.body.innerHTML; + document.body.innerHTML = original.replace( + '"moduleName":"remote/app"', + '"moduleName":"other/app"', + ); + expect(() => + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toThrow(/state metadata does not match/); + + document.body.innerHTML = original + original; + expect(() => + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toThrow(/Duplicate Bridge SSR identity/); + + document.body.innerHTML = original; + expect( + createBridgeHydrationRegistry(document).peek('other/app', 'remote-1'), + ).toBeUndefined(); + + document.body.innerHTML = original.replace( + '
', + 'unexpected', + ); + expect(() => + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toThrow(/only its direct mount and state children/); + + document.body.innerHTML = original.replace( + '"state":', + '"html":"duplicate","state":', + ); + expect(() => + createBridgeHydrationRegistry(document).peek('remote/app', 'remote-1'), + ).toThrow(/incompatible state envelope/); + }); + + it('allows the same instanceId across different module names', () => { + const second = { + ...result, + moduleName: 'other/app', + html: '

other remote

', + }; + document.body.innerHTML = + renderToStaticMarkup( + , + ) + + renderToStaticMarkup( + , + ); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('remote/app', 'remote-1')?.html).toBe( + '

server remote

', + ); + expect(registry.peek('other/app', 'remote-1')?.html).toBe( + '

other remote

', + ); + }); + + it('fail clears a peeked snapshot so later peeks miss', () => { + installSlot(); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('remote/app', 'remote-1')).toBeDefined(); + registry.fail('remote/app', 'remote-1'); + expect(registry.peek('remote/app', 'remote-1')).toBeUndefined(); + expect(registry.consume('remote/app', 'remote-1')).toBeUndefined(); + }); +}); diff --git a/packages/bridge/bridge-react/src/hydration.tsx b/packages/bridge/bridge-react/src/hydration.tsx new file mode 100644 index 00000000000..b131dfe162f --- /dev/null +++ b/packages/bridge/bridge-react/src/hydration.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + MF_BRIDGE_STATE_ATTR, + getBridgeSSRContainerAttrs, + getBridgeSSRSlotAttrs, + serializeBridgeSSRStateEnvelope, + type BridgeHydrationRegistry, + type BridgeHydrationSnapshot, + type BridgeSSRResult, +} from '@module-federation/bridge-shared'; +import { getRootDomDefaultClassName } from './utils'; + +const BridgeHydrationContext = React.createContext< + BridgeHydrationRegistry | undefined +>(undefined); + +export function BridgeHydrationProvider({ + registry, + children, +}: { + registry: BridgeHydrationRegistry; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useBridgeHydrationRegistry() { + return React.useContext(BridgeHydrationContext); +} + +export function BridgeRemoteSlot({ + moduleName, + instanceId, + payload, + snapshot, + className, + style, + mountRef, +}: { + moduleName: string; + instanceId: string; + payload?: BridgeSSRResult; + snapshot?: BridgeHydrationSnapshot; + className?: string; + style?: React.CSSProperties; + mountRef?: React.Ref; +}) { + const html = payload?.html ?? snapshot?.html ?? ''; + const state = payload?.dehydratedState ?? snapshot?.state; + const rootClassName = + `${getRootDomDefaultClassName(moduleName)} ${className || ''}`.trim(); + + return ( +
+
+ \u2028\u2029' })).toBe( + '{"value":"\\u003c/script>\\u2028\\u2029"}', + ); + }); + + it('rejects cyclic and non-JSON state', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => assertBridgeJSONValue(cyclic)).toThrow(/cyclic/); + expect(() => assertBridgeJSONValue({ value: Number.NaN })).toThrow( + /finite/, + ); + expect(() => + assertBridgeJSONValue(JSON.parse('{"__proto__":{"polluted":true}}')), + ).toThrow(/__proto__/); + }); + + it('treats empty SSR markup as hydration-eligible when markers match', async () => { + const { hasBridgeSSRMarkup, getBridgeSSRContainerAttrs } = + await import('./ssr'); + const attrs = getBridgeSSRContainerAttrs({ + moduleName: 'remote/app', + instanceId: 'remote-1', + }); + const dom = { + getAttribute(name: string) { + return attrs[name] ?? null; + }, + } as HTMLElement; + expect( + hasBridgeSSRMarkup(dom, { + moduleName: 'remote/app', + instanceId: 'remote-1', + }), + ).toBe(true); + }); + + it('validates host-carried results before matching their identity', () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

remote

', + } as const; + + expect( + getMatchingBridgeSSRResult(result, { + moduleName: 'remote/app', + instanceId: 'remote-1', + }), + ).toBe(result); + expect( + getMatchingBridgeSSRResult(result, { moduleName: 'other/app' }), + ).toBeUndefined(); + expect(() => + getMatchingBridgeSSRResult( + { ...result, protocolVersion: 2 }, + { moduleName: 'remote/app' }, + ), + ).toThrow(/incompatible result/); + }); + + it('creates an identity-only client reference', () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

remote

', + dehydratedState: { ready: true }, + } as const; + const reference = toBridgeSSRReference(result); + + expect(reference).toEqual({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + }); + expect(reference).not.toHaveProperty('html'); + expect(reference).not.toHaveProperty('dehydratedState'); + expect(Object.isFrozen(reference)).toBe(true); + expect( + getMatchingBridgeSSRPayload(reference, { + moduleName: 'remote/app', + instanceId: 'remote-1', + }), + ).toBe(reference); + }); + + it('serializes state separately from remote HTML', () => { + expect( + serializeBridgeSSRStateEnvelope({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + state: { value: '\u2028\u2029' }, + }), + ).toBe( + '{"protocolVersion":1,"moduleName":"remote/app","instanceId":"remote-1","state":{"value":"\\u003c/script>\\u2028\\u2029"}}', + ); + expect(() => + serializeBridgeSSRStateEnvelope({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'remote/app', + instanceId: 'remote-1', + html: '

must not be serialized

', + } as any), + ).toThrow(/state envelope is incompatible/); + }); + + it('loads, renders, and validates a remote provider', async () => { + const renderServer = vi.fn(async (context) => ({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: context.moduleName, + instanceId: context.instanceId, + html: '

remote

', + dehydratedState: { ready: true }, + })); + const request = new Request('http://bridge.test/detail'); + await expect( + renderRemoteBridge({ + loader: async () => ({ default: () => ({ renderServer }) }), + moduleName: 'remote/app', + instanceId: 'remote-1', + request, + props: { intentional: true }, + }), + ).resolves.toMatchObject({ html: '

remote

' }); + expect(renderServer).toHaveBeenCalledWith( + expect.objectContaining({ request, signal: request.signal }), + ); + }); + + it('adds context to load and contract failures', async () => { + await expect( + renderRemoteBridge({ + loader: async () => { + throw new Error('network'); + }, + moduleName: 'remote/app', + instanceId: 'remote-1', + request: new Request('http://bridge.test/'), + }), + ).rejects.toEqual( + expect.objectContaining({ + name: 'BridgeSSRError', + message: 'Unable to load Bridge remote remote/app', + }), + ); + + await expect( + renderRemoteBridge({ + loader: async () => ({ default: () => ({}) }), + moduleName: 'remote/app', + instanceId: 'remote-1', + request: new Request('http://bridge.test/'), + }), + ).rejects.toBeInstanceOf(BridgeSSRError); + }); +}); diff --git a/packages/bridge/bridge-shared/src/ssr.ts b/packages/bridge/bridge-shared/src/ssr.ts new file mode 100644 index 00000000000..10aca41c15a --- /dev/null +++ b/packages/bridge/bridge-shared/src/ssr.ts @@ -0,0 +1,500 @@ +import { + BRIDGE_SSR_PROTOCOL_VERSION, + BridgeSSRError, + type BridgeHydrationRegistry, + type BridgeHydrationSnapshot, + type BridgeJSONValue, + type BridgeSSRReference, + type BridgeSSRResult, + type BridgeSSRStateEnvelope, +} from './type'; + +export const MF_BRIDGE_SSR_ATTR = 'data-mf-bridge-ssr'; +export const MF_BRIDGE_VERSION_ATTR = 'data-mf-bridge-version'; +export const MF_BRIDGE_MODULE_ATTR = 'data-mf-bridge-module'; +export const MF_BRIDGE_INSTANCE_ATTR = 'data-mf-bridge-instance'; +export const MF_BRIDGE_SLOT_ATTR = 'data-mf-bridge-slot'; +export const MF_BRIDGE_MOUNT_ATTR = 'data-mf-bridge-mount'; +export const MF_BRIDGE_STATE_ATTR = 'data-mf-bridge-state'; + +function assertPrintable(value: string, label: string) { + if (!value || /[\u0000-\u001f\u007f]/.test(value)) { + throw new BridgeSSRError( + `Bridge SSR ${label} must be a non-empty printable string`, + ); + } +} + +export function assertBridgeSSRIdentity(value: { + moduleName: string; + instanceId: string; +}) { + assertPrintable(value.moduleName, 'moduleName'); + assertPrintable(value.instanceId, 'instanceId'); +} + +function validateJSON( + value: unknown, + path: string, + ancestors: WeakSet, +): void { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return; + } + if (typeof value === 'number') { + if (Number.isFinite(value)) return; + throw new BridgeSSRError(`Bridge SSR value at ${path} must be finite`); + } + if (typeof value !== 'object' || value === undefined) { + throw new BridgeSSRError( + `Bridge SSR value at ${path} must be JSON serializable`, + ); + } + if (ancestors.has(value)) { + throw new BridgeSSRError(`Bridge SSR value at ${path} must not be cyclic`); + } + ancestors.add(value); + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + throw new BridgeSSRError( + `Bridge SSR array at ${path} must not be sparse`, + ); + } + validateJSON(value[index], `${path}[${index}]`, ancestors); + } + } else { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new BridgeSSRError( + `Bridge SSR value at ${path} must use plain objects`, + ); + } + for (const [key, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(value), + )) { + if (key === '__proto__' || key === 'prototype' || key === 'constructor') { + throw new BridgeSSRError( + `Bridge SSR value at ${path} must not contain ${key}`, + ); + } + if ( + !descriptor.enumerable || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new BridgeSSRError( + `Bridge SSR value at ${path}.${key} must be an enumerable data property`, + ); + } + validateJSON(descriptor.value, `${path}.${key}`, ancestors); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new BridgeSSRError( + `Bridge SSR value at ${path} must not contain symbol keys`, + ); + } + } + ancestors.delete(value); +} + +export function assertBridgeJSONValue( + value: unknown, +): asserts value is BridgeJSONValue { + validateJSON(value, 'state', new WeakSet()); +} + +export function serializeBridgeJSON(value: BridgeJSONValue): string { + assertBridgeJSONValue(value); + return JSON.stringify(value) + .replace(/; + if ( + Object.keys(reference).some( + (key) => + key !== 'protocolVersion' && + key !== 'moduleName' && + key !== 'instanceId', + ) || + reference.protocolVersion !== BRIDGE_SSR_PROTOCOL_VERSION || + typeof reference.moduleName !== 'string' || + typeof reference.instanceId !== 'string' + ) { + throw new BridgeSSRError('Bridge SSR reference is incompatible'); + } + assertBridgeSSRIdentity({ + moduleName: reference.moduleName, + instanceId: reference.instanceId, + }); +} + +export function serializeBridgeSSRStateEnvelope( + envelope: BridgeSSRStateEnvelope, +): string { + const allowedKeys = new Set([ + 'protocolVersion', + 'moduleName', + 'instanceId', + 'state', + ]); + if ( + !envelope || + typeof envelope !== 'object' || + Array.isArray(envelope) || + Object.keys(envelope).some((key) => !allowedKeys.has(key)) + ) { + throw new BridgeSSRError('Bridge SSR state envelope is incompatible'); + } + assertBridgeSSRReference({ + protocolVersion: envelope.protocolVersion, + moduleName: envelope.moduleName, + instanceId: envelope.instanceId, + }); + if (envelope.state !== undefined) assertBridgeJSONValue(envelope.state); + return JSON.stringify(envelope) + .replace(/; + const allowedKeys = new Set([ + 'protocolVersion', + 'moduleName', + 'instanceId', + 'html', + 'dehydratedState', + ]); + if (Object.keys(result).some((key) => !allowedKeys.has(key))) { + throw new BridgeSSRError('Bridge SSR returned unsupported result fields'); + } + if ( + result.protocolVersion !== BRIDGE_SSR_PROTOCOL_VERSION || + typeof result.moduleName !== 'string' || + typeof result.instanceId !== 'string' || + typeof result.html !== 'string' + ) { + throw new BridgeSSRError('Bridge SSR returned an incompatible result'); + } + assertBridgeSSRIdentity({ + moduleName: result.moduleName, + instanceId: result.instanceId, + }); + if (result.dehydratedState !== undefined) { + assertBridgeJSONValue(result.dehydratedState); + } +} + +export function getMatchingBridgeSSRResult( + value: unknown, + expected: { moduleName?: string; instanceId?: string }, +): BridgeSSRResult | undefined { + if (value === undefined) return undefined; + assertBridgeSSRResult(value); + if ( + (expected.moduleName !== undefined && + value.moduleName !== expected.moduleName) || + (expected.instanceId !== undefined && + value.instanceId !== expected.instanceId) + ) { + return undefined; + } + return value; +} + +export function getMatchingBridgeSSRPayload( + value: unknown, + expected: { moduleName?: string; instanceId?: string }, +): BridgeSSRResult | BridgeSSRReference | undefined { + if (value === undefined) return undefined; + if (value && typeof value === 'object' && 'html' in value) { + assertBridgeSSRResult(value); + } else { + assertBridgeSSRReference(value); + } + if ( + (expected.moduleName !== undefined && + value.moduleName !== expected.moduleName) || + (expected.instanceId !== undefined && + value.instanceId !== expected.instanceId) + ) { + return undefined; + } + return value; +} + +export function getBridgeSSRContainerAttrs(value: { + moduleName: string; + instanceId: string; +}): Record { + assertBridgeSSRIdentity(value); + return { + [MF_BRIDGE_SSR_ATTR]: 'true', + [MF_BRIDGE_MOUNT_ATTR]: 'true', + [MF_BRIDGE_VERSION_ATTR]: String(BRIDGE_SSR_PROTOCOL_VERSION), + [MF_BRIDGE_MODULE_ATTR]: value.moduleName, + [MF_BRIDGE_INSTANCE_ATTR]: value.instanceId, + }; +} + +export function getBridgeSSRSlotAttrs(value: { + moduleName: string; + instanceId: string; +}): Record { + assertBridgeSSRIdentity(value); + return { + [MF_BRIDGE_SLOT_ATTR]: 'true', + [MF_BRIDGE_VERSION_ATTR]: String(BRIDGE_SSR_PROTOCOL_VERSION), + [MF_BRIDGE_MODULE_ATTR]: value.moduleName, + [MF_BRIDGE_INSTANCE_ATTR]: value.instanceId, + }; +} + +export function hasBridgeSSRMarkup( + dom: HTMLElement, + value?: { moduleName?: string; instanceId?: string }, +) { + // Empty SSR output is still hydration-eligible: trust markers/identity, not + // child presence. Remotes may render null/empty fragments with state only. + return ( + dom.getAttribute(MF_BRIDGE_SSR_ATTR) === 'true' && + dom.getAttribute(MF_BRIDGE_VERSION_ATTR) === + String(BRIDGE_SSR_PROTOCOL_VERSION) && + (!value?.moduleName || + dom.getAttribute(MF_BRIDGE_MODULE_ATTR) === value.moduleName) && + (!value?.instanceId || + dom.getAttribute(MF_BRIDGE_INSTANCE_ATTR) === value.instanceId) + ); +} + +function directChildrenWithAttribute( + parent: Element, + attribute: string, +): T[] { + return Array.from(parent.children).filter( + (child) => child.getAttribute(attribute) === 'true', + ) as T[]; +} + +function freezeJSONValue( + value: BridgeJSONValue | undefined, +): BridgeJSONValue | undefined { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + if (Array.isArray(value)) value.forEach((item) => freezeJSONValue(item)); + else Object.values(value).forEach((item) => freezeJSONValue(item)); + Object.freeze(value); + return value; +} + +function hydrationError(message: string, cause?: unknown) { + return new BridgeSSRError(message, cause); +} + +function hydrationIdentityKey(moduleName: string, instanceId: string) { + return `${moduleName}\0${instanceId}`; +} + +function readSlotSnapshot(slot: HTMLElement): BridgeHydrationSnapshot { + const versionAttr = slot.getAttribute(MF_BRIDGE_VERSION_ATTR); + const moduleName = slot.getAttribute(MF_BRIDGE_MODULE_ATTR) || ''; + const instanceId = slot.getAttribute(MF_BRIDGE_INSTANCE_ATTR) || ''; + assertBridgeSSRIdentity({ moduleName, instanceId }); + if (versionAttr !== String(BRIDGE_SSR_PROTOCOL_VERSION)) { + throw hydrationError( + `Bridge SSR slot ${moduleName}:${instanceId} uses unsupported protocol version ${versionAttr}`, + ); + } + const protocolVersion = BRIDGE_SSR_PROTOCOL_VERSION; + + const mounts = directChildrenWithAttribute( + slot, + MF_BRIDGE_MOUNT_ATTR, + ); + const scripts = directChildrenWithAttribute( + slot, + MF_BRIDGE_STATE_ATTR, + ); + if (mounts.length !== 1 || scripts.length !== 1) { + throw hydrationError( + `Bridge SSR slot ${instanceId} must contain exactly one direct mount and state child`, + ); + } + + const [mount] = mounts; + const [script] = scripts; + if ( + slot.children.length !== 2 || + slot.children[0] !== mount || + slot.children[1] !== script || + script.tagName !== 'SCRIPT' + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} must contain only its direct mount and state children in order`, + ); + } + if ( + mount.getAttribute(MF_BRIDGE_MODULE_ATTR) !== moduleName || + mount.getAttribute(MF_BRIDGE_INSTANCE_ATTR) !== instanceId || + mount.getAttribute(MF_BRIDGE_VERSION_ATTR) !== String(protocolVersion) + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} mount metadata does not match its slot`, + ); + } + if (script.type !== 'application/json' || !script.textContent) { + throw hydrationError( + `Bridge SSR slot ${instanceId} has an invalid state envelope`, + ); + } + + let envelope: BridgeSSRStateEnvelope; + try { + envelope = JSON.parse(script.textContent) as BridgeSSRStateEnvelope; + } catch (error) { + throw hydrationError( + `Bridge SSR slot ${instanceId} state envelope is not valid JSON`, + error, + ); + } + const envelopeKeys = Object.keys(envelope); + if ( + !envelope || + typeof envelope !== 'object' || + Array.isArray(envelope) || + envelopeKeys.some( + (key) => + key !== 'protocolVersion' && + key !== 'moduleName' && + key !== 'instanceId' && + key !== 'state', + ) + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} has an incompatible state envelope`, + ); + } + if ( + envelope.protocolVersion !== protocolVersion || + envelope.moduleName !== moduleName || + envelope.instanceId !== instanceId + ) { + throw hydrationError( + `Bridge SSR slot ${instanceId} state metadata does not match its slot`, + ); + } + if (envelope.state !== undefined) assertBridgeJSONValue(envelope.state); + + return Object.freeze({ + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName, + instanceId, + html: mount.innerHTML, + state: freezeJSONValue(envelope.state), + }); +} + +export function createBridgeHydrationRegistry( + root?: ParentNode, +): BridgeHydrationRegistry { + const hydrationRoot = + root ?? (typeof document === 'undefined' ? undefined : document); + if (!hydrationRoot) { + throw hydrationError( + 'createBridgeHydrationRegistry requires a document root', + ); + } + const snapshots = new Map(); + const errors = new Map(); + + for (const slot of Array.from( + hydrationRoot.querySelectorAll( + `[${MF_BRIDGE_SLOT_ATTR}="true"]`, + ), + )) { + const moduleName = slot.getAttribute(MF_BRIDGE_MODULE_ATTR) || ''; + const instanceId = slot.getAttribute(MF_BRIDGE_INSTANCE_ATTR) || ''; + const key = hydrationIdentityKey(moduleName, instanceId); + if (snapshots.has(key) || errors.has(key)) { + snapshots.delete(key); + errors.set( + key, + hydrationError( + `Duplicate Bridge SSR identity ${moduleName}:${instanceId}`, + ), + ); + continue; + } + try { + const snapshot = readSlotSnapshot(slot); + snapshots.set( + hydrationIdentityKey(snapshot.moduleName, snapshot.instanceId), + snapshot, + ); + } catch (error) { + errors.set( + key, + error instanceof BridgeSSRError + ? error + : hydrationError( + `Unable to read Bridge SSR slot ${moduleName}:${instanceId}`, + error, + ), + ); + } + } + + const peek = (moduleName: string, instanceId: string) => { + const key = hydrationIdentityKey(moduleName, instanceId); + const error = errors.get(key); + if (error) throw error; + return snapshots.get(key); + }; + + return { + peek, + consume(moduleName, instanceId) { + const key = hydrationIdentityKey(moduleName, instanceId); + const snapshot = peek(moduleName, instanceId); + snapshots.delete(key); + errors.delete(key); + return snapshot; + }, + fail(moduleName, instanceId) { + const key = hydrationIdentityKey(moduleName, instanceId); + snapshots.delete(key); + errors.delete(key); + }, + }; +} diff --git a/packages/bridge/bridge-shared/src/type.ts b/packages/bridge/bridge-shared/src/type.ts index e788d81a6ac..4e1c86874ea 100644 --- a/packages/bridge/bridge-shared/src/type.ts +++ b/packages/bridge/bridge-shared/src/type.ts @@ -1,5 +1,88 @@ import type { CSSProperties } from 'react'; +export const BRIDGE_SSR_PROTOCOL_VERSION = 1 as const; + +export type BridgeJSONValue = + | null + | boolean + | number + | string + | BridgeJSONValue[] + | { [key: string]: BridgeJSONValue }; + +export type BridgeSSRPrepareResult

> = { + props?: P; + dehydratedState?: BridgeJSONValue; +}; + +export type BridgeSSRConfig

> = + | boolean + | { + prepare?: ( + context: BridgeServerRenderContext

, + ) => + | void + | BridgeSSRPrepareResult

+ | Promise>; + hydrate?: ( + state: BridgeJSONValue | undefined, + ) => Partial

| Record; + }; + +export type BridgeServerRenderContext

> = { + moduleName: string; + instanceId: string; + request: Request; + signal: AbortSignal; + props: P; +}; + +export type BridgeSSRResult = { + protocolVersion: typeof BRIDGE_SSR_PROTOCOL_VERSION; + moduleName: string; + instanceId: string; + /** Trusted remote HTML. Remotes are treated as trusted HTML producers. */ + html: string; + dehydratedState?: BridgeJSONValue; +}; + +/** Identity-only payload safe to serialize into a host hydration context. */ +export type BridgeSSRReference = Pick< + BridgeSSRResult, + 'protocolVersion' | 'moduleName' | 'instanceId' +>; + +export type BridgeSSRStateEnvelope = BridgeSSRReference & { + state?: BridgeJSONValue; +}; + +export type BridgeHydrationSnapshot = BridgeSSRReference & { + html: string; + state?: BridgeJSONValue; +}; + +export interface BridgeHydrationRegistry { + peek( + moduleName: string, + instanceId: string, + ): BridgeHydrationSnapshot | undefined; + consume( + moduleName: string, + instanceId: string, + ): BridgeHydrationSnapshot | undefined; + fail(moduleName: string, instanceId: string): void; +} + +export class BridgeSSRError extends Error { + override readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'BridgeSSRError'; + this.cause = cause; + } +} + export interface ProviderParams { moduleName?: string; basename?: string; @@ -11,4 +94,15 @@ export interface ProviderParams { export interface RenderFnParams extends ProviderParams { dom: HTMLElement; + instanceId?: string; + ssrState?: BridgeJSONValue; + signal?: AbortSignal; +} + +export interface BridgeProviderAPI { + render: (info: RenderFnParams) => void | Promise; + destroy: (info: { dom: HTMLElement }) => void; + renderServer?: ( + context: BridgeServerRenderContext, + ) => Promise; } diff --git a/packages/bridge/vue3-bridge/README.md b/packages/bridge/vue3-bridge/README.md index 0bfecb08f8a..9728beb8e41 100644 --- a/packages/bridge/vue3-bridge/README.md +++ b/packages/bridge/vue3-bridge/README.md @@ -7,3 +7,7 @@ This template should help get you started developing with Vue 3 and TypeScript i - [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (previously Volar) and disable Vetur - Use [vue-tsc](https://github.com/vuejs/language-tools/tree/master/packages/tsc) for performing the same type checking from the command line, or for generating d.ts files for SFCs. + +## Bridge SSR + +See the [Bridge SSR guide](https://module-federation.io/guide/bridge/ssr) for hydration setup with Vue 3. diff --git a/packages/bridge/vue3-bridge/__tests__/remoteApp.ssr.test.ts b/packages/bridge/vue3-bridge/__tests__/remoteApp.ssr.test.ts new file mode 100644 index 00000000000..bf3b7604151 --- /dev/null +++ b/packages/bridge/vue3-bridge/__tests__/remoteApp.ssr.test.ts @@ -0,0 +1,341 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; +import { + createApp, + defineComponent, + h, + KeepAlive, + nextTick, + reactive, +} from 'vue'; +import { createMemoryHistory, createRouter, RouterView } from 'vue-router'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + createBridgeHydrationRegistry, + MF_BRIDGE_SSR_ATTR, + toBridgeSSRReference, +} from '@module-federation/bridge-shared'; +import RemoteApp from '../src/remoteApp'; +import { provideBridgeHydrationRegistry } from '../src/hydration'; + +rs.mock('@module-federation/runtime', () => ({ + getInstance: () => ({ + bridgeHook: { + lifecycle: { + beforeBridgeRender: { emit: rs.fn(async () => ({})) }, + afterBridgeRender: { emit: rs.fn() }, + beforeBridgeDestroy: { emit: rs.fn() }, + afterBridgeDestroy: { emit: rs.fn() }, + }, + }, + }), +})); + +const flushBridgeRender = async () => { + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +const installSlot = () => { + document.body.innerHTML = + '

' + + '

server remote

' + + '' + + '
'; +}; + +describe('RemoteApp SSR lifecycle', () => { + let root: HTMLDivElement; + + beforeEach(() => { + document.body.innerHTML = ''; + root = document.createElement('div'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + const mountApp = (app: ReturnType) => { + document.body.appendChild(root); + app.mount(root); + }; + + it('claims the registry snapshot before provider render', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

server remote

', + dehydratedState: { ready: true }, + }; + installSlot(); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('ecApp', 'ec:1')).toBeDefined(); + + const providerReturn = { + render: rs.fn(), + destroy: rs.fn(), + }; + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: 'ec:1', + ssr: toBridgeSSRReference(result), + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + provideBridgeHydrationRegistry(app, registry); + // RemoteApp calls useRoute(); give it a router even for the non-route case. + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].ssrState).toEqual({ + ready: true, + }); + expect(registry.peek('ecApp', 'ec:1')).toBeUndefined(); + app.unmount(); + }); + + it('claims SSR once under KeepAlive and reactivates through CSR', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

server remote

', + dehydratedState: { ready: true }, + }; + installSlot(); + const registry = createBridgeHydrationRegistry(document); + const providerReturn = { + render: rs.fn(), + destroy: rs.fn(), + }; + const RemoteRoute = defineComponent({ + setup() { + return () => + h(RemoteApp, { + moduleName: 'ecApp', + basename: '/ec', + instanceId: 'ec:1', + ssr: toBridgeSSRReference(result), + providerInfo: () => providerReturn, + }); + }, + }); + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
home
' } }, + { path: '/ec/:pathMatch(.*)*', component: RemoteRoute }, + ], + }); + + const App = defineComponent({ + setup() { + return () => + h(RouterView, null, { + default: ({ Component }) => + h(KeepAlive, null, () => (Component ? h(Component) : null)), + }); + }, + }); + + const app = createApp(App); + provideBridgeHydrationRegistry(app, registry); + app.use(router); + + await router.push('/ec'); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].ssrState).toEqual({ + ready: true, + }); + expect(registry.peek('ecApp', 'ec:1')).toBeUndefined(); + + await router.push('/'); + await flushBridgeRender(); + expect(providerReturn.destroy).toHaveBeenCalledTimes(1); + + await router.push('/ec'); + await flushBridgeRender(); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].ssrState).toBeUndefined(); + const remountDom = providerReturn.render.mock.calls[1][0] + .dom as HTMLElement; + expect(remountDom.getAttribute(MF_BRIDGE_SSR_ATTR)).toBeNull(); + + app.unmount(); + }); + + it('releases an unclaimed SSR snapshot when unmounted before render settles', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

server remote

', + dehydratedState: { ready: true }, + }; + installSlot(); + const registry = createBridgeHydrationRegistry(document); + expect(registry.peek('ecApp', 'ec:1')).toBeDefined(); + + const providerReturn = { + render: rs.fn( + () => + new Promise(() => { + /* never settles */ + }), + ), + destroy: rs.fn(), + }; + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: 'ec:1', + ssr: toBridgeSSRReference(result), + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + provideBridgeHydrationRegistry(app, registry); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + // Unmount before the render queue microtask claims the snapshot. + app.unmount(); + await flushBridgeRender(); + expect(registry.peek('ecApp', 'ec:1')).toBeUndefined(); + }); + + it('drops SSR slot markup when ssr props clear without remount', async () => { + const result = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

server remote

', + dehydratedState: { ready: true }, + }; + const providerReturn = { + render: rs.fn(async () => undefined), + destroy: rs.fn(), + }; + const state = reactive<{ + ssr: typeof result | undefined; + }>({ ssr: result }); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: 'ec:1', + ssr: state.ssr, + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(root.querySelector('[data-mf-bridge-slot="true"]')).not.toBeNull(); + expect(root.innerHTML).toContain('server remote'); + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.destroy).not.toHaveBeenCalled(); + + state.ssr = undefined; + await nextTick(); + await flushBridgeRender(); + + expect(root.querySelector('[data-mf-bridge-slot="true"]')).toBeNull(); + expect(root.querySelector(`[${MF_BRIDGE_SSR_ATTR}="true"]`)).toBeNull(); + expect(providerReturn.destroy).toHaveBeenCalledTimes(1); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].ssrState).toBeUndefined(); + expect(providerReturn.render.mock.calls[1][0].dom?.isConnected).toBe(true); + app.unmount(); + }); + + it('replaces the provider when the SSR identity changes in place', async () => { + const first = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:1', + html: '

first remote

', + dehydratedState: { ready: 'first' }, + }; + const second = { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: 'ecApp', + instanceId: 'ec:2', + html: '

second remote

', + dehydratedState: { ready: 'second' }, + }; + const providerReturn = { + render: rs.fn(async () => undefined), + destroy: rs.fn(), + }; + const state = reactive<{ + instanceId: string; + ssr: typeof first; + }>({ instanceId: first.instanceId, ssr: first }); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + instanceId: state.instanceId, + ssr: state.ssr, + providerInfo: () => providerReturn, + }), + }); + const app = createApp(App); + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }); + app.use(router); + await router.isReady(); + mountApp(app); + await flushBridgeRender(); + + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].ssrState).toEqual({ + ready: 'first', + }); + + state.instanceId = second.instanceId; + state.ssr = second; + await nextTick(); + await flushBridgeRender(); + + expect(providerReturn.destroy).toHaveBeenCalledTimes(1); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].ssrState).toEqual({ + ready: 'second', + }); + expect(root.innerHTML).toContain('second remote'); + app.unmount(); + }); +}); diff --git a/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts b/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts index ffc10f60dfd..e65634f2e8d 100644 --- a/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts +++ b/packages/bridge/vue3-bridge/__tests__/remoteApp.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; -import { createApp, defineComponent, h, KeepAlive, nextTick } from 'vue'; +import { + createApp, + defineComponent, + h, + KeepAlive, + nextTick, + ref as vueRef, +} from 'vue'; import { createMemoryHistory, createRouter, RouterView } from 'vue-router'; import RemoteApp from '../src/remoteApp'; @@ -9,6 +16,7 @@ const { dispatchPopstateEnv } = rs.hoisted(() => ({ rs.mock('@module-federation/bridge-shared', () => ({ dispatchPopstateEnv, + getMatchingBridgeSSRPayload: () => undefined, })); rs.mock('@module-federation/runtime', () => ({ @@ -96,4 +104,72 @@ describe('RemoteApp', () => { app.unmount(); }); + + it('forwards updated application props to the mounted provider', async () => { + const providerReturn = { + render: rs.fn(), + destroy: rs.fn(), + }; + const providerInfo = () => providerReturn; + const label = vueRef('first'); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + providerInfo, + label: label.value, + }), + }); + const app = createApp(App); + app.mount(root); + await flushBridgeRender(); + expect(providerReturn.render).toHaveBeenCalledTimes(1); + expect(providerReturn.render.mock.calls[0][0].label).toBe('first'); + + label.value = 'second'; + await flushBridgeRender(); + expect(providerReturn.render).toHaveBeenCalledTimes(2); + expect(providerReturn.render.mock.calls[1][0].label).toBe('second'); + app.unmount(); + }); + + it('replaces a provider that changes during an asynchronous mount', async () => { + let finishFirstRender!: () => void; + const firstProvider = { + render: rs.fn( + () => + new Promise((resolve) => { + finishFirstRender = resolve; + }), + ), + destroy: rs.fn(), + }; + const secondProvider = { + render: rs.fn(), + destroy: rs.fn(), + }; + const providerInfo = vueRef(() => firstProvider); + const App = defineComponent({ + setup: () => () => + h(RemoteApp, { + moduleName: 'ecApp', + providerInfo: providerInfo.value, + }), + }); + const app = createApp(App); + app.mount(root); + await nextTick(); + await Promise.resolve(); + expect(firstProvider.render).toHaveBeenCalledOnce(); + + providerInfo.value = () => secondProvider; + await nextTick(); + finishFirstRender(); + await flushBridgeRender(); + await flushBridgeRender(); + + expect(firstProvider.destroy).toHaveBeenCalledOnce(); + expect(secondProvider.render).toHaveBeenCalledOnce(); + app.unmount(); + }); }); diff --git a/packages/bridge/vue3-bridge/package.json b/packages/bridge/vue3-bridge/package.json index 8bd03c00161..60e3ed10737 100644 --- a/packages/bridge/vue3-bridge/package.json +++ b/packages/bridge/vue3-bridge/package.json @@ -15,6 +15,22 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "node": { + "import": "./dist/index.server.js", + "require": "./dist/index.server.cjs" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./*": "./*" + }, "files": [ "dist/", "package.json", @@ -28,7 +44,13 @@ }, "peerDependencies": { "vue": "=3", - "vue-router": "=4" + "vue-router": "=4", + "@vue/server-renderer": "^3.0.0" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } }, "dependencies": { "@module-federation/bridge-shared": "workspace:*", @@ -45,6 +67,7 @@ "vite": "^5.4.21", "vue": "^3.5.30", "vue-router": "4.4.5", + "@vue/server-renderer": "^3.5.30", "vue-tsc": "^2.2.12" } } diff --git a/packages/bridge/vue3-bridge/rstest.config.ts b/packages/bridge/vue3-bridge/rstest.config.ts index c24e78e6123..5d1bb8841bb 100644 --- a/packages/bridge/vue3-bridge/rstest.config.ts +++ b/packages/bridge/vue3-bridge/rstest.config.ts @@ -4,6 +4,11 @@ import { pluginVueJsx } from '@rsbuild/plugin-vue-jsx'; import { resolve } from 'path'; export default defineConfig({ + source: { + define: { + __APP_VERSION__: '"unknown"', + }, + }, plugins: [ pluginBabel({ include: /\.(?:jsx|tsx)$/, diff --git a/packages/bridge/vue3-bridge/src/create.ts b/packages/bridge/vue3-bridge/src/create.ts index 9fe415acee8..2c3033220dd 100644 --- a/packages/bridge/vue3-bridge/src/create.ts +++ b/packages/bridge/vue3-bridge/src/create.ts @@ -2,9 +2,18 @@ import { type AsyncComponentOptions, defineAsyncComponent, h } from 'vue'; import { useRoute } from 'vue-router'; import RemoteApp from './remoteApp.jsx'; import { LoggerInstance } from './utils.js'; +import type { + BridgeSSRReference, + BridgeSSRResult, +} from '@module-federation/bridge-shared'; declare const __APP_VERSION__: string; +export type RemoteAppSSRProps = { + ssr?: BridgeSSRResult | BridgeSSRReference; + instanceId?: string; +}; + export function createRemoteAppComponent(info: { loader: () => Promise; export?: string; diff --git a/packages/bridge/vue3-bridge/src/hydration.ts b/packages/bridge/vue3-bridge/src/hydration.ts new file mode 100644 index 00000000000..1375ac12bff --- /dev/null +++ b/packages/bridge/vue3-bridge/src/hydration.ts @@ -0,0 +1,17 @@ +import { inject, type App, type InjectionKey } from 'vue'; +import type { BridgeHydrationRegistry } from '@module-federation/bridge-shared'; + +export const BridgeHydrationRegistryKey: InjectionKey = + Symbol.for('mf.bridge.hydration-registry'); + +export function provideBridgeHydrationRegistry( + app: App, + registry: BridgeHydrationRegistry, +) { + app.provide(BridgeHydrationRegistryKey, registry); + return app; +} + +export function useBridgeHydrationRegistry() { + return inject(BridgeHydrationRegistryKey, undefined); +} diff --git a/packages/bridge/vue3-bridge/src/index.server.ts b/packages/bridge/vue3-bridge/src/index.server.ts new file mode 100644 index 00000000000..9743a3fb20a --- /dev/null +++ b/packages/bridge/vue3-bridge/src/index.server.ts @@ -0,0 +1,18 @@ +import { + createBridgeComponentWithServerRenderer, + type ProviderFnParams, +} from './provider'; +import { renderBridgeVueToString } from './server-renderer'; + +export function createBridgeComponent(bridgeInfo: ProviderFnParams) { + return createBridgeComponentWithServerRenderer( + bridgeInfo, + renderBridgeVueToString, + ); +} + +export type { ProviderFnParams } from './provider'; +export { createRemoteComponent, createRemoteAppComponent } from './create'; +export type { RemoteAppSSRProps } from './create'; +export { provideBridgeHydrationRegistry } from './hydration'; +export type { RenderFnParams } from '@module-federation/bridge-shared'; diff --git a/packages/bridge/vue3-bridge/src/index.ts b/packages/bridge/vue3-bridge/src/index.ts index fbf7a554075..1de0572548e 100644 --- a/packages/bridge/vue3-bridge/src/index.ts +++ b/packages/bridge/vue3-bridge/src/index.ts @@ -1,4 +1,6 @@ export { createBridgeComponent } from './provider'; export type { ProviderFnParams } from './provider'; export { createRemoteComponent, createRemoteAppComponent } from './create'; +export type { RemoteAppSSRProps } from './create'; +export { provideBridgeHydrationRegistry } from './hydration'; export type { RenderFnParams } from '@module-federation/bridge-shared'; diff --git a/packages/bridge/vue3-bridge/src/provider.spec.ts b/packages/bridge/vue3-bridge/src/provider.spec.ts new file mode 100644 index 00000000000..0979d3afc28 --- /dev/null +++ b/packages/bridge/vue3-bridge/src/provider.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, rs } from '@rstest/core'; +import { defineComponent, h } from 'vue'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import { getBridgeSSRContainerAttrs } from '@module-federation/bridge-shared'; +import { createBridgeComponentWithServerRenderer } from './provider'; + +describe('Vue Bridge server provider', () => { + it('creates a request-local app and returns the thin V1 result', async () => { + const serverRenderer = rs.fn(async () => '

vue remote

'); + const factory = createBridgeComponentWithServerRenderer( + { + rootComponent: defineComponent(() => () => h('p', 'vue remote')), + appOptions: () => ({ + router: createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/detail', + component: defineComponent(() => () => h('p')), + }, + ], + }), + }), + ssr: true, + }, + serverRenderer, + ); + const provider = factory(); + const request = new Request('http://bridge.test/detail'); + await expect( + provider.renderServer!({ + moduleName: 'vue/remote', + instanceId: 'vue-1', + request, + signal: request.signal, + props: {}, + }), + ).resolves.toEqual({ + protocolVersion: 1, + moduleName: 'vue/remote', + instanceId: 'vue-1', + html: '

vue remote

', + dehydratedState: undefined, + }); + expect(serverRenderer).toHaveBeenCalledOnce(); + }); + + it('updates application props without remounting the app', async () => { + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent({ + props: { label: String }, + setup: (props) => () => h('p', props.label), + }), + appOptions: () => undefined, + })(); + const dom = document.createElement('div'); + document.body.appendChild(dom); + + await provider.render({ + dom, + moduleName: 'vue/remote', + label: 'first', + } as any); + const firstApp = (dom as HTMLElement & { __vue_app__?: any }).__vue_app__; + const unmount = rs.spyOn(firstApp, 'unmount'); + + await provider.render({ + dom, + moduleName: 'vue/remote', + label: 'second', + } as any); + await Promise.resolve(); + expect(unmount).not.toHaveBeenCalled(); + expect(dom.textContent).toBe('second'); + expect((dom as HTMLElement & { __vue_app__?: any }).__vue_app__).toBe( + firstApp, + ); + + await provider.render({ dom, moduleName: 'vue/remote' } as any); + await Promise.resolve(); + expect(dom.textContent).toBe(''); + + provider.destroy({ dom }); + expect(unmount).toHaveBeenCalledOnce(); + }); + + it('remounts when routing configuration changes', async () => { + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent(() => () => h('p', 'remote')), + appOptions: () => undefined, + })(); + const dom = document.createElement('div'); + document.body.appendChild(dom); + await provider.render({ + dom, + moduleName: 'vue/remote', + basename: '/first', + }); + const firstApp = (dom as HTMLElement & { __vue_app__?: any }).__vue_app__; + const unmount = rs.spyOn(firstApp, 'unmount'); + + await provider.render({ + dom, + moduleName: 'vue/remote', + basename: '/second', + }); + expect(unmount).toHaveBeenCalledOnce(); + expect((dom as HTMLElement & { __vue_app__?: any }).__vue_app__).not.toBe( + firstApp, + ); + }); + + it('applies dehydrated state only during the initial hydration mount', async () => { + const hydrate = rs.fn(() => ({ label: 'hydrated' })); + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent({ + props: { label: String }, + setup: (props) => () => h('p', props.label), + }), + appOptions: () => undefined, + ssr: { hydrate }, + })(); + const dom = document.createElement('div'); + for (const [name, value] of Object.entries( + getBridgeSSRContainerAttrs({ + moduleName: 'vue/remote', + instanceId: 'vue-1', + }), + )) { + dom.setAttribute(name, value); + } + dom.innerHTML = '

hydrated

'; + document.body.appendChild(dom); + + await provider.render({ + dom, + moduleName: 'vue/remote', + instanceId: 'vue-1', + ssrState: { label: 'server' }, + }); + await provider.render({ + dom, + moduleName: 'vue/remote', + instanceId: 'vue-1', + label: 'updated', + } as any); + + expect(hydrate).toHaveBeenCalledOnce(); + expect(dom.textContent).toBe('updated'); + provider.destroy({ dom }); + }); + + it('ignores apps created before an aborted client mount', async () => { + const controller = new AbortController(); + const provider = createBridgeComponentWithServerRenderer({ + rootComponent: defineComponent(() => () => h('p', 'remote')), + appOptions: () => { + queueMicrotask(() => controller.abort(new Error('cancelled'))); + return { + router: createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/', + component: defineComponent(() => () => h('p')), + }, + ], + }), + }; + }, + })(); + const dom = document.createElement('div'); + document.body.appendChild(dom); + await expect( + provider.render({ + dom, + moduleName: 'vue/remote', + memoryRoute: { entryPath: '/' }, + signal: controller.signal, + }), + ).resolves.toBeUndefined(); + expect( + (dom as HTMLElement & { __vue_app__?: any }).__vue_app__, + ).toBeUndefined(); + }); +}); diff --git a/packages/bridge/vue3-bridge/src/provider.ts b/packages/bridge/vue3-bridge/src/provider.ts index 6b3e66eee41..2e68ac3e6f7 100644 --- a/packages/bridge/vue3-bridge/src/provider.ts +++ b/packages/bridge/vue3-bridge/src/provider.ts @@ -1,6 +1,14 @@ import * as Vue from 'vue'; import * as VueRouter from 'vue-router'; -import { RenderFnParams } from '@module-federation/bridge-shared'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + hasBridgeSSRMarkup, + type BridgeSSRConfig, + type BridgeSSRPrepareResult, + type BridgeSSRResult, + type BridgeServerRenderContext, + type RenderFnParams, +} from '@module-federation/bridge-shared'; import { LoggerInstance } from './utils'; import { getInstance } from '@module-federation/runtime'; import { processRoutes } from './routeUtils'; @@ -11,6 +19,9 @@ type AddOptionsFnParams = { app: Vue.App; basename: RenderFnParams['basename']; memoryRoute: RenderFnParams['memoryRoute']; + request?: Request; + signal?: AbortSignal; + ssr: boolean; [key: string]: any; }; @@ -18,95 +29,239 @@ export type ProviderFnParams = { rootComponent: Vue.Component; appOptions: (params: AddOptionsFnParams) => { router?: VueRouter.Router; - /** Called with the bridge's internal router after creation but before navigation. - * Use this to register global guards (beforeEach, afterEach, etc.) that would - * otherwise be lost when the bridge recreates the router. */ afterRouterCreate?: (router: VueRouter.Router) => void; } | void; + ssr?: BridgeSSRConfig>; }; -export function createBridgeComponent(bridgeInfo: ProviderFnParams) { - const rootMap = new Map(); - const instance = getInstance(); +export type BridgeVueServerRenderer = ( + app: Vue.App, + context: Record, +) => string | Promise; + +type BridgeAppInfo = Omit & { + dom?: HTMLElement; + request?: Request; + url?: string; +}; + +async function setupBridgeApp( + bridgeInfo: ProviderFnParams, + info: BridgeAppInfo, + props: Record, + mode: 'ssr' | 'hydrate' | 'csr', +) { + if (info.signal?.aborted) throw info.signal.reason; + const ReactiveBridgeRoot = Vue.defineComponent({ + name: 'BridgeReactiveRoot', + setup: () => () => Vue.h(bridgeInfo.rootComponent, props), + }); + const app = (mode === 'csr' ? Vue.createApp : Vue.createSSRApp)( + ReactiveBridgeRoot, + ); + const options = bridgeInfo.appOptions({ + ...props, + app, + basename: info.basename, + memoryRoute: info.memoryRoute, + hashRoute: info.hashRoute, + request: info.request, + signal: info.signal, + ssr: mode === 'ssr', + }); + let router: VueRouter.Router | undefined; + if (options?.router) { + const effectiveMemoryRoute = + mode === 'ssr' ? { entryPath: info.url ?? '/' } : info.memoryRoute; + const processed = processRoutes({ + router: options.router, + basename: info.basename, + memoryRoute: effectiveMemoryRoute, + hashRoute: info.hashRoute, + }); + router = VueRouter.createRouter({ + ...options.router.options, + history: processed.history, + routes: processed.routes, + }); + processed.patchRouter?.(router); + options.afterRouterCreate?.(router); + if (effectiveMemoryRoute) { + await router.push(effectiveMemoryRoute.entryPath); + } + app.use(router); + if (mode !== 'csr') await router.isReady(); + } + if (info.signal?.aborted) throw info.signal.reason; + return { app, router }; +} + +function serverLocation(request: Request, basename?: string) { + const url = new URL(request.url); + let pathname = url.pathname || '/'; + const base = (basename || '/').replace(/\/$/, '') || '/'; + if (base !== '/' && (pathname === base || pathname.startsWith(`${base}/`))) { + pathname = pathname.slice(base.length) || '/'; + } + return `${pathname}${url.search}`; +} + +export function createBridgeComponentWithServerRenderer( + bridgeInfo: ProviderFnParams, + serverRenderer?: BridgeVueServerRenderer, +) { + const roots = new Map< + HTMLElement, + { + app: Vue.App; + props: Record; + routeKey: string; + } + >(); + const runtime = getInstance(); + return () => { - return { + const config = + typeof bridgeInfo.ssr === 'object' ? bridgeInfo.ssr : undefined; + const provider: { + __APP_VERSION__: string; + render: (info: RenderFnParams) => Promise; + destroy: (info: { dom: HTMLElement }) => void; + renderServer?: ( + context: BridgeServerRenderContext>, + ) => Promise; + } = { __APP_VERSION__, - async render(info: RenderFnParams) { - LoggerInstance.debug(`createBridgeComponent render Info`, info); + async render(info) { + if (info.signal?.aborted) return; const { moduleName, dom, basename, memoryRoute, hashRoute, - ...propsInfo + instanceId, + ssrState, + signal, + ...applicationProps } = info; - const app = Vue.createApp(bridgeInfo.rootComponent, propsInfo); - rootMap.set(dom, app); - const beforeBridgeRenderRes = - await instance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(info); - + await runtime?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit(info); const extraProps = beforeBridgeRenderRes && typeof beforeBridgeRenderRes === 'object' && - beforeBridgeRenderRes?.extraProps - ? beforeBridgeRenderRes?.extraProps + beforeBridgeRenderRes.extraProps + ? beforeBridgeRenderRes.extraProps : {}; - - const bridgeOptions = bridgeInfo.appOptions({ - app, - basename, - memoryRoute, - hashRoute, - ...propsInfo, - ...extraProps, - }); - if (bridgeOptions?.router) { - const { history, routes, patchRouter } = processRoutes({ - router: bridgeOptions.router, - basename: info.basename, - memoryRoute: info.memoryRoute, - hashRoute: info.hashRoute, - }); - - const router = VueRouter.createRouter({ - ...bridgeOptions.router.options, - history, - routes, - }); - - if (patchRouter) { - patchRouter(router); - } - - if (bridgeOptions.afterRouterCreate) { - bridgeOptions.afterRouterCreate(router); - } - - LoggerInstance.debug(`createBridgeComponent render router info>>>`, { + const routeKey = JSON.stringify([ + basename ?? null, + memoryRoute?.entryPath ?? null, + hashRoute ?? false, + ]); + const mounted = roots.get(dom); + const shouldHydrate = + !mounted && + hasBridgeSSRMarkup(dom, { moduleName, - router, + instanceId, }); - // memory route Initializes the route - if (memoryRoute) { - await router.push(memoryRoute.entryPath); + const hydrated = + shouldHydrate && config?.hydrate ? config.hydrate(ssrState) : {}; + const nextProps = { ...hydrated, ...applicationProps, ...extraProps }; + if (mounted?.routeKey === routeKey) { + for (const key of Object.keys(mounted.props)) { + if (!(key in nextProps)) delete mounted.props[key]; } - - app.use(router); + Object.assign(mounted.props, nextProps); + runtime?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info); + return; } - - app.mount(dom); - instance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info); + if (mounted) { + mounted.app.unmount(); + roots.delete(dom); + } + const reactiveProps = Vue.shallowReactive(nextProps); + let app: Vue.App | undefined; + try { + ({ app } = await setupBridgeApp( + bridgeInfo, + { basename, memoryRoute, hashRoute, instanceId, signal }, + reactiveProps, + shouldHydrate ? 'hydrate' : 'csr', + )); + } catch (error) { + if (signal?.aborted) return; + throw error; + } + if (signal?.aborted) { + // Never mounted: drop the request-local app without unmount warnings. + return; + } + app.mount(dom, shouldHydrate); + if (signal?.aborted || !dom.isConnected) { + app.unmount(); + return; + } + roots.set(dom, { app, props: reactiveProps, routeKey }); + runtime?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(info); + LoggerInstance.debug('createBridgeComponent rendered', { moduleName }); }, - destroy(info: { dom: HTMLElement }) { - LoggerInstance.debug(`createBridgeComponent destroy Info`, info); - const root = rootMap.get(info?.dom); - - instance?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(info); - root?.unmount(); - instance?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(info); + destroy({ dom }) { + const info = { dom }; + LoggerInstance.debug('createBridgeComponent destroy Info', info); + runtime?.bridgeHook?.lifecycle?.beforeBridgeDestroy?.emit(info); + roots.get(dom)?.app.unmount(); + roots.delete(dom); + runtime?.bridgeHook?.lifecycle?.afterBridgeDestroy?.emit(info); }, }; + + if (serverRenderer && bridgeInfo.ssr) { + provider.renderServer = async (context) => { + if (context.signal.aborted) throw context.signal.reason; + const preparedValue = await config?.prepare?.(context); + if (context.signal.aborted) throw context.signal.reason; + const prepared = (preparedValue || {}) as BridgeSSRPrepareResult< + Record + >; + const renderInfo = prepared.props ?? context.props; + const { basename, memoryRoute, hashRoute, ...applicationProps } = + renderInfo; + delete applicationProps.moduleName; + const { app } = await setupBridgeApp( + bridgeInfo, + { + moduleName: context.moduleName, + basename: basename as string | undefined, + memoryRoute: memoryRoute as RenderFnParams['memoryRoute'], + hashRoute: Boolean(hashRoute), + request: context.request, + signal: context.signal, + url: serverLocation( + context.request, + basename as string | undefined, + ), + }, + applicationProps, + 'ssr', + ); + if (context.signal.aborted) throw context.signal.reason; + const ssrContext: Record = {}; + const html = await serverRenderer(app, ssrContext); + if (context.signal.aborted) throw context.signal.reason; + return { + protocolVersion: BRIDGE_SSR_PROTOCOL_VERSION, + moduleName: context.moduleName, + instanceId: context.instanceId, + html, + dehydratedState: prepared.dehydratedState, + }; + }; + } + return provider; }; } + +export function createBridgeComponent(bridgeInfo: ProviderFnParams) { + return createBridgeComponentWithServerRenderer(bridgeInfo); +} diff --git a/packages/bridge/vue3-bridge/src/remoteApp.tsx b/packages/bridge/vue3-bridge/src/remoteApp.tsx index 3a04bb951f2..f431545f561 100644 --- a/packages/bridge/vue3-bridge/src/remoteApp.tsx +++ b/packages/bridge/vue3-bridge/src/remoteApp.tsx @@ -1,18 +1,45 @@ import { ref, + computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, + onUpdated, watch, + watchEffect, defineComponent, useAttrs, nextTick, + type PropType, } from 'vue'; -import { dispatchPopstateEnv } from '@module-federation/bridge-shared'; +import { + BRIDGE_SSR_PROTOCOL_VERSION, + dispatchPopstateEnv, + getBridgeSSRContainerAttrs, + getBridgeSSRSlotAttrs, + getMatchingBridgeSSRPayload, + MF_BRIDGE_INSTANCE_ATTR, + MF_BRIDGE_MODULE_ATTR, + MF_BRIDGE_MOUNT_ATTR, + MF_BRIDGE_SSR_ATTR, + MF_BRIDGE_VERSION_ATTR, + serializeBridgeSSRStateEnvelope, + type BridgeSSRReference, + type BridgeSSRResult, +} from '@module-federation/bridge-shared'; import { useRoute } from 'vue-router'; import { LoggerInstance } from './utils'; import { getInstance } from '@module-federation/runtime'; +import { useBridgeHydrationRegistry } from './hydration'; + +function clearBridgeSSRMountAttrs(dom: HTMLElement) { + dom.removeAttribute(MF_BRIDGE_SSR_ATTR); + dom.removeAttribute(MF_BRIDGE_MOUNT_ATTR); + dom.removeAttribute(MF_BRIDGE_VERSION_ATTR); + dom.removeAttribute(MF_BRIDGE_MODULE_ATTR); + dom.removeAttribute(MF_BRIDGE_INSTANCE_ATTR); +} export default defineComponent({ name: 'RemoteApp', @@ -23,6 +50,8 @@ export default defineComponent({ hashRoute: Boolean, providerInfo: Function, rootAttrs: Object, + ssr: Object as PropType, + instanceId: String, }, inheritAttrs: false, setup(props) { @@ -32,52 +61,206 @@ export default defineComponent({ const isRendered = ref(false); const isActive = ref(false); const wasDeactivated = ref(false); + const renderError = ref(); + const controller = new AbortController(); + let renderQueue = Promise.resolve(); + let consumedSnapshot = false; + let hydratedOnce = false; + let csrOnly = false; + let providerGeneration = 0; const route = useRoute(); const hostInstance = getInstance(); const componentAttrs = useAttrs(); + let lastComponentAttrs = { ...componentAttrs }; + const ssrPayload = computed(() => + getMatchingBridgeSSRPayload(props.ssr, { + moduleName: props.moduleName, + instanceId: props.instanceId, + }), + ); + const instanceId = computed( + () => props.instanceId || ssrPayload.value?.instanceId, + ); + const serverPayload = computed(() => { + const payload = ssrPayload.value; + return payload && 'html' in payload + ? (payload as BridgeSSRResult) + : undefined; + }); + const reference = computed(() => { + const payload = ssrPayload.value; + return payload && !('html' in payload) + ? (payload as BridgeSSRReference) + : undefined; + }); + const registry = useBridgeHydrationRegistry(); + watchEffect(() => { + if (reference.value && !registry) { + throw new Error( + 'Bridge SSR references require provideBridgeHydrationRegistry before mount', + ); + } + }); + const snapshot = computed(() => { + const currentReference = reference.value; + const currentInstanceId = instanceId.value; + return currentReference && currentInstanceId + ? registry?.peek(currentReference.moduleName, currentInstanceId) + : undefined; + }); + const hasSSRPayload = computed(() => + Boolean((serverPayload.value || snapshot.value) && instanceId.value), + ); + const ssrIdentity = computed(() => { + const payload = ssrPayload.value; + const currentInstanceId = instanceId.value; + return payload && currentInstanceId + ? `${payload.moduleName}\0${currentInstanceId}` + : ''; + }); + const registryModuleName = computed( + () => + props.moduleName || + reference.value?.moduleName || + ssrPayload.value?.moduleName || + '', + ); + + const releaseUnclaimedSnapshot = () => { + const currentSnapshot = snapshot.value; + const currentInstanceId = instanceId.value; + if ( + !currentSnapshot || + !currentInstanceId || + consumedSnapshot || + !registry + ) { + return; + } + registry.fail(registryModuleName.value, currentInstanceId); + consumedSnapshot = true; + }; const getBridgeRenderProps = () => ({ - name: props.moduleName, + moduleName: props.moduleName, dom: rootRef.value, basename: props.basename, memoryRoute: props.memoryRoute, hashRoute: props.hashRoute, }); - const renderComponent = async () => { - if (!rootRef.value || isRendered.value) { - return; - } - const providerReturn = props.providerInfo?.(); - providerInfoRef.value = providerReturn; + const renderComponent = () => { + const pending = renderQueue.then(async () => { + const generation = providerGeneration; + const dom = rootRef.value as HTMLElement | null; + if ( + !dom || + !dom.isConnected || + !props.providerInfo || + controller.signal.aborted + ) { + return; + } + const providerReturn = providerInfoRef.value || props.providerInfo?.(); + providerInfoRef.value = providerReturn; + const wasRendered = isRendered.value; - let renderProps = { - ...componentAttrs, - moduleName: props.moduleName, - dom: rootRef.value, - basename: props.basename, - memoryRoute: props.memoryRoute, - hashRoute: props.hashRoute, - }; - LoggerInstance.debug( - `createRemoteAppComponent LazyComponent render >>>`, - renderProps, - ); + // Claim before any await so cancel cannot leave a peekable snapshot. + // After a claim (even if render aborts), never hydrate again — CSR only. + const currentSnapshot = snapshot.value; + const currentInstanceId = instanceId.value; + let ssrState = + wasRendered || hydratedOnce || consumedSnapshot + ? undefined + : serverPayload.value?.dehydratedState; + if ( + !wasRendered && + !hydratedOnce && + !consumedSnapshot && + currentSnapshot && + currentInstanceId && + registry + ) { + const claimed = registry.consume( + registryModuleName.value, + currentInstanceId, + ); + if (Object.is(claimed, currentSnapshot)) { + consumedSnapshot = true; + ssrState = currentSnapshot.state; + } else { + consumedSnapshot = true; + hydratedOnce = true; + ssrState = undefined; + clearBridgeSSRMountAttrs(dom); + } + } - const beforeBridgeRenderRes = - (await hostInstance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit( + let renderProps = { + ...componentAttrs, + moduleName: props.moduleName, + dom, + basename: props.basename, + memoryRoute: props.memoryRoute, + hashRoute: props.hashRoute, + instanceId: currentInstanceId, + ssrState, + signal: controller.signal, + }; + LoggerInstance.debug( + `createRemoteAppComponent LazyComponent render >>>`, renderProps, - )) || {}; + ); - renderProps = { ...renderProps, ...beforeBridgeRenderRes.extraProps }; - providerReturn.render(renderProps); - isRendered.value = true; - hostInstance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit(renderProps); + const beforeBridgeRenderRes = + (await hostInstance?.bridgeHook?.lifecycle?.beforeBridgeRender?.emit( + renderProps, + )) || {}; + renderProps = { ...renderProps, ...beforeBridgeRenderRes.extraProps }; + await providerReturn.render(renderProps); + if ( + generation !== providerGeneration || + controller.signal.aborted || + wasDeactivated.value || + !dom.isConnected + ) { + // Only destroy if this provider is still the active one. A newer + // providerInfo watch may already have destroyed and replaced it. + if (providerInfoRef.value === providerReturn) { + providerReturn.destroy?.({ dom }); + providerInfoRef.value = null; + isRendered.value = false; + } + return; + } + isRendered.value = true; + // One-shot hydrate for this instance. Keep SSR markers in the DOM so + // hosts/tests can observe them; strip only on KeepAlive deactivate. + if (snapshot.value || serverPayload.value) { + hydratedOnce = true; + } + hostInstance?.bridgeHook?.lifecycle?.afterBridgeRender?.emit( + renderProps, + ); + }); + renderQueue = pending.catch((error) => { + if (controller.signal.aborted || wasDeactivated.value) return; + const failedSnapshot = snapshot.value; + const failedInstanceId = instanceId.value; + if (failedSnapshot && failedInstanceId && !consumedSnapshot) { + registry?.fail(registryModuleName.value, failedInstanceId); + consumedSnapshot = true; + } + renderError.value = error; + }); + return renderQueue; }; const destroyComponent = () => { const providerReturn = providerInfoRef.value as any; - if (!providerReturn || !isRendered.value) { + // Destroy whenever a provider was obtained. A failed/aborted render may + // still have mounted an app before rejecting, so do not gate on isRendered. + if (!providerReturn) { return; } LoggerInstance.debug( @@ -125,7 +308,83 @@ export default defineComponent({ onMounted(() => { isActive.value = true; - renderComponent(); + void renderComponent(); + }); + + watch( + hasSSRPayload, + (next, prev) => { + if (!prev || next) return; + // Host cleared SSR without remounting (SPA after seedConsumed). Destroy + // the provider on the current mount node before Vue drops the slot tree, + // then CSR-render into the replacement mount. + providerGeneration += 1; + csrOnly = true; + hydratedOnce = true; + const dom = rootRef.value as HTMLElement | null; + destroyComponent(); + if (dom) clearBridgeSSRMountAttrs(dom); + void nextTick(() => { + if (isActive.value && !controller.signal.aborted) { + void renderComponent(); + } + }); + }, + { flush: 'pre' }, + ); + + watch( + ssrIdentity, + (next, prev) => { + if (!prev || !next || next === prev) return; + + // A new SSR identity is a new remote instance. Tear down the old + // provider before Vue patches the slot, then hydrate/render the new + // payload after its mount node has been committed. + providerGeneration += 1; + consumedSnapshot = false; + hydratedOnce = false; + csrOnly = false; + destroyComponent(); + void nextTick(() => { + if (isActive.value && !controller.signal.aborted) { + void renderComponent(); + } + }); + }, + { flush: 'pre' }, + ); + + watch( + () => props.providerInfo, + () => { + providerGeneration += 1; + destroyComponent(); + providerInfoRef.value = null; + void renderComponent(); + }, + { flush: 'post' }, + ); + + watch( + () => [props.basename, props.memoryRoute, props.hashRoute], + () => { + if (isRendered.value) void renderComponent(); + }, + { deep: true, flush: 'post' }, + ); + + onUpdated(() => { + const nextComponentAttrs = { ...componentAttrs }; + const keys = Object.keys(nextComponentAttrs); + const attrsChanged = + keys.length !== Object.keys(lastComponentAttrs).length || + keys.some( + (key) => !Object.is(nextComponentAttrs[key], lastComponentAttrs[key]), + ); + if (!attrsChanged) return; + lastComponentAttrs = nextComponentAttrs; + if (isRendered.value) void renderComponent(); }); onActivated(async () => { @@ -135,20 +394,81 @@ export default defineComponent({ } wasDeactivated.value = false; await nextTick(); - renderComponent(); + await renderComponent(); }); onDeactivated(() => { isActive.value = false; wasDeactivated.value = true; + releaseUnclaimedSnapshot(); + if (consumedSnapshot) hydratedOnce = true; + csrOnly = true; + const dom = rootRef.value as HTMLElement | null; + if (dom) clearBridgeSSRMountAttrs(dom); destroyComponent(); }); onBeforeUnmount(() => { + controller.abort(); watchStopHandle(); + releaseUnclaimedSnapshot(); destroyComponent(); }); - return () =>
; + return () => { + if (renderError.value) throw renderError.value; + const currentInstanceId = instanceId.value; + const currentServerPayload = serverPayload.value; + const currentSnapshot = snapshot.value; + const currentSsrPayload = ssrPayload.value; + const showSSRMarkup = + hasSSRPayload.value && currentInstanceId && !csrOnly; + const mount = ( +
+ ); + if (!showSSRMarkup) return mount; + const moduleName = props.moduleName || currentSsrPayload!.moduleName; + return ( +
+ {mount} +