diff --git a/package.json b/package.json index 213efbb5..cc6525f8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,20 @@ "workspaces": [ "packages/**" ], + "files": [ + "packages/lib/dist/*", + "packages/types/*" + ], + "main": "./packages/lib/dist/index.js", + "module": "./packages/lib/dist/index.mjs", + "types": "./packages/lib/types/index.d.ts", + "exports": { + ".": { + "types": "./packages/lib/types/index.d.ts", + "import": "./packages/lib/dist/index.mjs", + "require": "./packages/lib/dist/index.js" + } + }, "engines": { "node": "^14.18.0 || >=16.0.0", "pnpm": ">=8.0.1" @@ -12,7 +26,7 @@ "license": "MulanPSL-2.0", "scripts": { "preinstall": "npx only-allow pnpm", - "prepare": "husky install", + "prepare": "pnpm run build", "postinstall": "npx playwright install", "lint-staged": "lint-staged", "format": "prettier -w packages/lib/**/*.ts", diff --git a/packages/examples/react-vite/package.json b/packages/examples/react-vite/package.json index b2fa7473..2e6f9b27 100644 --- a/packages/examples/react-vite/package.json +++ b/packages/examples/react-vite/package.json @@ -8,6 +8,7 @@ "build:remotes": "pnpm --parallel --filter \"./remote\" build", "serve:remotes": "pnpm --parallel --filter \"./remote\" serve", "dev:hosts": "pnpm --filter \"./host\" dev", + "dev:remotes": "pnpm --filter \"./remote\" dev", "stop": "kill-port --port 5000,5001" }, "devDependencies": { diff --git a/packages/examples/vue3-advanced-demo/package.json b/packages/examples/vue3-advanced-demo/package.json index e08cfe8f..bc991d2f 100644 --- a/packages/examples/vue3-advanced-demo/package.json +++ b/packages/examples/vue3-advanced-demo/package.json @@ -15,6 +15,7 @@ "build:remotes": "pnpm --parallel --filter \"./team-blue\" --filter \"./team-green\" build", "serve:remotes": "pnpm --parallel --filter \"./team-blue\" --filter \"./team-green\" serve", "dev:hosts": "pnpm --filter \"./team-red\" dev", + "dev:remotes": "pnpm --parallel --filter \"./team-blue\" --filter \"./team-green\" dev", "stop": "kill-port --port 5000,5001,5002", "clean": "pnpm run clean" }, diff --git a/packages/lib/src/dev/expose-development.ts b/packages/lib/src/dev/expose-development.ts index 35d94ca0..00af7c30 100644 --- a/packages/lib/src/dev/expose-development.ts +++ b/packages/lib/src/dev/expose-development.ts @@ -13,17 +13,91 @@ // SPDX-License-Identifier: MulanPSL-2.0 // ***************************************************************************** -import { parseExposeOptions } from '../utils' -import { parsedOptions } from '../public' +import { resolve } from 'path' +import { getModuleMarker, normalizePath, parseExposeOptions } from '../utils' +import { EXTERNALS, SHARED, builderInfo, parsedOptions } from '../public' import type { VitePluginFederationOptions } from 'types' import type { PluginHooks } from '../../types/pluginHooks' +import { UserConfig, ViteDevServer } from 'vite' +import { importShared } from './import-shared' export function devExposePlugin( options: VitePluginFederationOptions ): PluginHooks { parsedOptions.devExpose = parseExposeOptions(options) + let moduleMap = '' + let remoteFile: string | null = null + + const exposeModules = (baseDir) => { + for (const item of parsedOptions.devExpose) { + const moduleName = getModuleMarker(`\${${item[0]}}`, SHARED) + EXTERNALS.push(moduleName) + const importPath = normalizePath(item[1].import) + const exposeFilepath = normalizePath(resolve(item[1].import)) + moduleMap += `\n"${item[0]}":() => { + return __federation_import('/${importPath}', '${baseDir}@fs/${exposeFilepath}').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},` + } + } + + const buildRemoteFile = (baseDir) => { + return `(${importShared})(); + import RefreshRuntime from "${baseDir}@react-refresh" + RefreshRuntime.injectIntoGlobalHook(window) + window.$RefreshReg$ = () => {} + window.$RefreshSig$ = () => (type) => type + window.__vite_plugin_react_preamble_installed__ = true + const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']); + let moduleMap = { + ${moduleMap} + }; + const __federation_import = async (urlImportPath, fsImportPath) => { + let importedModule; + try { + return await import(fsImportPath); + }catch(ex) { + return await import(urlImportPath); + } + }; + export const get =(module) => { + if(!moduleMap[module]) throw new Error('Can not find remote module ' + module) + return moduleMap[module](); + }; + export const init =(shareScope) => { + globalThis.__federation_shared__= globalThis.__federation_shared__|| {}; + Object.entries(shareScope).forEach(([key, value]) => { + const versionKey = Object.keys(value)[0]; + const versionValue = Object.values(value)[0]; + const scope = versionValue.scope || 'default' + globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {}; + const shared= globalThis.__federation_shared__[scope]; + (shared[key] = shared[key]||{})[versionKey] = versionValue; + }); + } + ` + } return { - name: 'originjs:expose-development' + name: 'originjs:expose-development', + config: (config: UserConfig) => { + if (config.base) { + exposeModules(config.base) + remoteFile = buildRemoteFile(config.base) + } + }, + configureServer: (server: ViteDevServer) => { + const remoteFilePath = `${builderInfo.assetsDir}/${options.filename}` + server.middlewares.use((req, res, next) => { + if (req.url && req.url.includes(remoteFilePath)) { + res.writeHead(200, 'OK', { + 'Content-Type': 'text/javascript', + 'Access-Control-Allow-Origin': '*' + }) + res.write(remoteFile) + res.end() + } else { + next() + } + }) + } } } diff --git a/packages/lib/src/dev/import-shared.js b/packages/lib/src/dev/import-shared.js new file mode 100644 index 00000000..b1d3441b --- /dev/null +++ b/packages/lib/src/dev/import-shared.js @@ -0,0 +1,40 @@ +export const importShared = function () { + if (!globalThis.importShared) { + const moduleCache = Object.create(null) + const getSharedFromRuntime = async (name, shareScope) => { + let module = null + if (globalThis?.__federation_shared__?.[shareScope]?.[name]) { + const versionObj = globalThis.__federation_shared__[shareScope][name] + const versionValue = Object.values(versionObj)[0] + module = await (await versionValue.get())() + } + if (module) { + return flattenModule(module, name) + } + } + const flattenModule = (module, name) => { + // use a shared module which export default a function will getting error 'TypeError: xxx is not a function' + if (typeof module.default === 'function') { + Object.keys(module).forEach((key) => { + if (key !== 'default') { + module.default[key] = module[key] + } + }) + moduleCache[name] = module.default + return module.default + } + if (module.default) module = Object.assign({}, module.default, module) + moduleCache[name] = module + return module + } + globalThis.importShared = async (name, shareScope = 'default') => { + try { + return moduleCache[name] + ? new Promise((r) => r(moduleCache[name])) + : (await getSharedFromRuntime(name, shareScope)) || null + } catch (ex) { + console.log(ex) + } + } + } +} diff --git a/packages/lib/src/dev/remote-development.ts b/packages/lib/src/dev/remote-development.ts index a107556a..868bdc7d 100644 --- a/packages/lib/src/dev/remote-development.ts +++ b/packages/lib/src/dev/remote-development.ts @@ -14,7 +14,11 @@ // ***************************************************************************** import type { UserConfig } from 'vite' -import type { ConfigTypeSet, VitePluginFederationOptions } from 'types' +import type { + ConfigTypeSet, + ExposesConfig, + VitePluginFederationOptions +} from 'types' import { walk } from 'estree-walker' import MagicString from 'magic-string' import { readFileSync } from 'fs' @@ -30,6 +34,10 @@ import { } from '../utils' import { builderInfo, parsedOptions, devRemotes } from '../public' import type { PluginHooks } from '../../types/pluginHooks' +import { Literal } from 'estree' +import { importShared } from './import-shared' + +const exposedItems: string[] = [] export function devRemotePlugin( options: VitePluginFederationOptions @@ -206,6 +214,8 @@ export {__federation_method_ensure, __federation_method_getRemote , __federation return } + code += `(${importShared})();\n` + let ast: AcornNode | null = null try { ast = this.parse(code) @@ -215,7 +225,6 @@ export {__federation_method_ensure, __federation_method_getRemote , __federation if (!ast) { return null } - const magicString = new MagicString(code) const hasStaticImported = new Map() @@ -223,12 +232,87 @@ export {__federation_method_ensure, __federation_method_getRemote , __federation let manualRequired: any = null // set static import if exists walk(ast, { enter(node: any) { + if ( + node.type === 'MemberExpression' && + node.object.type === 'MemberExpression' && + node.object.object.type === 'MetaProperty' && + node.object.object.meta.name === 'import' && + node.object.property.type === 'Identifier' && + node.object.property.name === 'env' && + node.property.name === 'BASE_URL' + ) { + const serverPort = viteDevServer.config.inlineConfig.server?.port + const baseUrlFromConfig = + viteDevServer.config.env.BASE_URL && + viteDevServer.config.env.BASE_URL !== '/' + ? viteDevServer.config.env.BASE_URL + : '' + // This assumes that the dev server will always be running on localhost. That's probably not a good assumption, but I don't know how to work around it right now. + const baseUrl = `"//localhost:${serverPort}${baseUrlFromConfig}"` + magicString.overwrite(node.start, node.end, baseUrl) + node = { type: 'Literal', value: baseUrl } as Literal + } if ( node.type === 'ImportDeclaration' && node.source?.value === 'virtual:__federation__' ) { manualRequired = node } + if ( + isExposed(id, parsedOptions.devExpose) && + node.type === 'ImportDeclaration' && + node.source?.value + ) { + const moduleName = node.source.value + if ( + parsedOptions.devShared.some( + (sharedInfo) => sharedInfo[0] === moduleName + ) + ) { + const namedImportDeclaration: (string | never)[] = [] + let defaultImportDeclaration: string | null = null + if (!node.specifiers?.length) { + // invalid import , like import './__federation_shared_lib.js' , and remove it + magicString.remove(node.start, node.end) + } else { + node.specifiers.forEach((specify) => { + if (specify.imported?.name) { + namedImportDeclaration.push( + `${ + specify.imported.name === specify.local.name + ? specify.imported.name + : `${specify.imported.name}:${specify.local.name}` + }` + ) + } else { + defaultImportDeclaration = specify.local.name + } + }) + + if (defaultImportDeclaration && namedImportDeclaration.length) { + // import a, {b} from 'c' -> const a = await importShared('c'); const {b} = a; + const imports = namedImportDeclaration.join(',') + const line = `const ${defaultImportDeclaration} = await importShared('${moduleName}') || await import('${moduleName}');\nconst {${imports}} = ${defaultImportDeclaration};\n` + + magicString.overwrite(node.start, node.end, line) + } else if (defaultImportDeclaration) { + magicString.overwrite( + node.start, + node.end, + `const ${defaultImportDeclaration} = await importShared('${moduleName}') || await import('${moduleName}');\n` + ) + } else if (namedImportDeclaration.length) { + magicString.overwrite( + node.start, + node.end, + `const {${namedImportDeclaration.join( + ',' + )}} = await importShared('${moduleName}') || await import('${moduleName}');\n` + ) + } + } + } + } if ( (node.type === 'ImportExpression' || @@ -411,4 +495,27 @@ export {__federation_method_ensure, __federation_method_getRemote , __federation } return res } + function isExposed(id: string, options: (string | ConfigTypeSet)[]) { + if (exposedItems.includes(id)) { + return true + } + if (options.length >= 2 && (options[1] as ExposesConfig).import) { + if (normalizePath((options[1] as ExposesConfig).import)) { + return true + } + } + for (let i = 0, length = options.length; i < length; i++) { + const item = options[i] + if ( + Array.isArray(item) && + item.length >= 2 && + (item[1] as ExposesConfig).import + ) { + if (normalizePath((item[1] as ExposesConfig).import)) { + return true + } + } + } + return false + } }