Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bridge-ssr-v1.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions .github/workflows/e2e-bridge-ssr.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
1 change: 1 addition & 0 deletions apps/bridge-ssr-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/dist/
17 changes: 17 additions & 0 deletions apps/bridge-ssr-demo/README.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions apps/bridge-ssr-demo/host-vue/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
25 changes: 25 additions & 0 deletions apps/bridge-ssr-demo/host-vue/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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/,
},
],
});
49 changes: 49 additions & 0 deletions apps/bridge-ssr-demo/host-vue/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -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' },
),
],
});
11 changes: 11 additions & 0 deletions apps/bridge-ssr-demo/host-vue/server.mjs
Original file line number Diff line number Diff line change
@@ -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;
},
);
76 changes: 76 additions & 0 deletions apps/bridge-ssr-demo/host-vue/src/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { RemoteReactApp } from './remoteApps';
import type { HostSSRContext } from './ssrContext';

const props = defineProps<{ ssrContext?: HostSSRContext }>();
const route = useRoute();
const locationUrl = computed(
() =>
`${route.path}${route.fullPath.includes('?') ? `?${route.fullPath.split('?')[1]}` : ''}`,
);
const seedPath = props.ssrContext?.url.split('?')[0].split('#')[0];
const seedConsumed = ref(false);

watch(
() => route.path,
(path) => {
if (seedPath && path !== seedPath) seedConsumed.value = true;
},
{ immediate: true },
);

const activeContext = computed(() => {
if (seedConsumed.value) return undefined;
return seedPath === route.path ? props.ssrContext : undefined;
});
</script>

<template>
<div class="bridge-ssr-host bridge-ssr-vue-host" :data-location="locationUrl">
<nav>
<RouterLink to="/">Home</RouterLink> |
<RouterLink class="host-react-remote-link" to="/react-remote"
>React Remote</RouterLink
>
|
<RouterLink to="/react-pair">React Pair</RouterLink>
</nav>

<main v-if="route.path.startsWith('/react-pair')">
<h2>React Remote Pair</h2>
<RemoteReactApp
module-name="bridge_ssr_react"
basename="/react-pair"
name="Left"
:age="1"
:ssr="activeContext?.reactPair?.[0]"
:instance-id="activeContext?.reactPair?.[0]?.instanceId"
/>
<RemoteReactApp
module-name="bridge_ssr_react"
basename="/react-pair"
name="Right"
:age="2"
:ssr="activeContext?.reactPair?.[1]"
:instance-id="activeContext?.reactPair?.[1]?.instanceId"
/>
</main>
<main v-else-if="route.path.startsWith('/react-remote')">
<h2>React Remote</h2>
<RemoteReactApp
module-name="bridge_ssr_react"
basename="/react-remote"
name="Ming"
:age="12"
:ssr="activeContext?.reactRemote"
:instance-id="activeContext?.reactRemote?.instanceId"
/>
</main>
<main v-else>
<h1>Bridge SSR Host</h1>
<p>Vue outer host consuming a React application remote.</p>
</main>
</div>
</template>
16 changes: 16 additions & 0 deletions apps/bridge-ssr-demo/host-vue/src/entry.client.ts
Original file line number Diff line number Diff line change
@@ -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');
34 changes: 34 additions & 0 deletions apps/bridge-ssr-demo/host-vue/src/entry.server.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
4 changes: 4 additions & 0 deletions apps/bridge-ssr-demo/host-vue/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// <reference types="@rsbuild/core/types" />

declare module 'bridge_ssr_react/export-app';
declare module 'bridge_ssr_vue/export-app';
11 changes: 11 additions & 0 deletions apps/bridge-ssr-demo/host-vue/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bridge SSR Vue Host</title>
</head>
<body>
<div id="root"><!--app-content--></div>
</body>
</html>
51 changes: 51 additions & 0 deletions apps/bridge-ssr-demo/host-vue/src/prepareSSRContext.ts
Original file line number Diff line number Diff line change
@@ -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<HostSSRContext> {
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<Record<string, unknown>>(
'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;
}
5 changes: 5 additions & 0 deletions apps/bridge-ssr-demo/host-vue/src/remoteApps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRemoteAppComponent } from '@module-federation/bridge-vue3';

export const RemoteReactApp = createRemoteAppComponent({
loader: () => import('bridge_ssr_react/export-app'),
});
Loading