diff --git a/libs/native-federation/src/builders/build/builder.ts b/libs/native-federation/src/builders/build/builder.ts index c345d5c0..23c0e6a9 100644 --- a/libs/native-federation/src/builders/build/builder.ts +++ b/libs/native-federation/src/builders/build/builder.ts @@ -1,9 +1,7 @@ import * as path from 'path'; -import * as fs from 'fs'; -import * as mrmime from 'mrmime'; import { ApplicationBuilderOptions } from '@angular/build/src/builders/application'; -import { Schema } from '@angular/build/src/builders/application/schema'; +import { OutputHashing, Schema } from '@angular/build/src/builders/application/schema'; import { BuilderContext, @@ -15,71 +13,27 @@ import { buildApplication, buildApplicationInternal, } from '@angular/build/src/builders/application'; -import { serveWithVite } from '@angular/build/src/builders/dev-server/vite-server'; -import { DevServerBuilderOptions } from '@angular-devkit/build-angular'; -import { normalizeOptions } from '@angular-devkit/build-angular/src/builders/dev-server/options'; -import { setLogLevel, logger } from '@softarc/native-federation/build'; +import { logger } from '@softarc/native-federation/build'; -import { FederationOptions } from '@softarc/native-federation/build'; -import { setBuildAdapter } from '@softarc/native-federation/build'; -import { - createAngularBuildAdapter, - setMemResultHandler, -} from '../../utils/angular-esbuild-adapter'; -import { getExternals } from '@softarc/native-federation/build'; -import { loadFederationConfig } from '@softarc/native-federation/build'; -import { buildForFederation } from '@softarc/native-federation/build'; import { targetFromTargetString } from '@angular-devkit/architect'; -import { NfBuilderSchema } from './schema'; -import { - reloadBrowser, - reloadShell, - setError, - startServer, -} from '../../utils/dev-server'; -import { RebuildHubs } from '../../utils/rebuild-events'; -import { updateIndexHtml, updateScriptTags } from '../../utils/updateIndexHtml'; -import { existsSync, mkdirSync, rmSync } from 'fs'; -import { - EsBuildResult, - MemResults, - NgCliAssetResult, -} from '../../utils/mem-resuts'; import { JsonObject } from '@angular-devkit/core'; +import type { Plugin } from 'esbuild'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { entryPointsPlugin } from '../../utils/entry-points-plugin'; +import { externalsPlugin } from '../../utils/externals-plugin'; +import { initFederationBuild } from '../../utils/init-federation-build'; import { createSharedMappingsPlugin } from '../../utils/shared-mappings-plugin'; -import { Connect } from 'vite'; -import { PluginBuild } from 'esbuild'; -import { FederationInfo } from '@softarc/native-federation-runtime'; - -function _buildApplication(options, context, pluginsOrExtensions) { - let extensions; - if (pluginsOrExtensions && Array.isArray(pluginsOrExtensions)) { - extensions = { - codePlugins: pluginsOrExtensions, - }; - } else { - extensions = pluginsOrExtensions; - } - return buildApplicationInternal( - options, - context, - { write: false }, - extensions - ); -} +import { transformIndexHtml, updateScriptTags } from '../../utils/updateIndexHtml'; +import { NfBuilderSchema } from './schema'; export async function* runBuilder( - nfOptions: NfBuilderSchema, + rawOptions: Schema, context: BuilderContext ): AsyncIterable { - let target = targetFromTargetString(nfOptions.target); - - let _options = (await context.getTargetOptions( - target - )) as unknown as JsonObject & Schema; + let target = targetFromTargetString(rawOptions.target); let builder = await context.getBuilderNameForTarget(target); @@ -105,37 +59,23 @@ export async function* runBuilder( } let options = (await context.validateOptions( - _options, + rawOptions, builder )) as JsonObject & Schema; - const outerOptions = options as DevServerBuilderOptions; - const normOuterOptions = nfOptions.dev - ? await normalizeOptions(context, context.target.project, outerOptions) - : null; - - if (nfOptions.dev) { - target = targetFromTargetString(outerOptions.buildTarget); - _options = (await context.getTargetOptions( - target - )) as unknown as JsonObject & Schema; - - builder = await context.getBuilderNameForTarget(target); - options = (await context.validateOptions(_options, builder)) as JsonObject & - Schema; + // we don't want builder to clear "dist" as long as initFederationBuild will put remoteEntry.json there before real build + options.deleteOutputPath = false; + // it is impossible to hash files because initFederationBuild calc their names before real build + // TODO: could pass through by patching remoteEntry.json after build, but there would be troubles with serve though + options.outputHashing = OutputHashing.None; + // federation responding for downloading all the parts, there is no need for builder to preload them + if (typeof options.index !== 'boolean') { + options.index = { + input: typeof options.index === 'string' ? options.index : options.index.input, + preloadInitial: false, + } } - const runServer = !!nfOptions.port; - const write = !runServer; - const watch = !!runServer || nfOptions.watch; - - options.watch = watch; - const rebuildEvents = new RebuildHubs(); - - const adapter = createAngularBuildAdapter(options, context, rebuildEvents); - setBuildAdapter(adapter); - - setLogLevel(options.verbose ? 'verbose' : 'info'); const outputPath = options.outputPath; @@ -154,200 +94,25 @@ export async function* runBuilder( outputOptions.browser ); - const fedOptions: FederationOptions = { - workspaceRoot: context.workspaceRoot, - outputPath: browserOutputPath, - federationConfig: infereConfigPath(options.tsConfig), - tsConfig: options.tsConfig, - verbose: options.verbose, - watch: false, // options.watch, - dev: !!nfOptions.dev, - }; - - const config = await loadFederationConfig(fedOptions); - const externals = getExternals(config); - const plugins = [ - createSharedMappingsPlugin(config.sharedMappings), - { - name: 'externals', - setup(build: PluginBuild) { - if (build.initialOptions.platform !== 'node') { - build.initialOptions.external = externals.filter( - (e) => e !== 'tslib' - ); - } - }, - }, - ]; - - const middleware: Connect.NextHandleFunction[] = [ - (req, res, next) => { - const fileName = path.join( - fedOptions.workspaceRoot, - fedOptions.outputPath, - req.url - ); - const exists = fs.existsSync(fileName); - - if (req.url !== '/' && req.url !== '' && exists) { - const lookup = mrmime.lookup; - const mimeType = lookup(path.extname(fileName)) || 'text/javascript'; - const rawBody = fs.readFileSync(fileName, 'utf-8'); - const body = addDebugInformation(req.url, rawBody); - res.writeHead(200, { - 'Content-Type': mimeType, - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', - 'Access-Control-Allow-Headers': 'Content-Type', - }); - res.end(body); - } else { - next(); - } - }, - ]; - - const memResults = new MemResults(); - - let first = true; - let lastResult: { success: boolean } | undefined; - - if (existsSync(fedOptions.outputPath)) { - rmSync(fedOptions.outputPath, { recursive: true }); - } - - if (!existsSync(fedOptions.outputPath)) { - mkdirSync(fedOptions.outputPath, { recursive: true }); + if (existsSync(browserOutputPath)) { + rmSync(browserOutputPath, { recursive: true }); } - - if (!write) { - setMemResultHandler((outFiles, outDir) => { - const fullOutDir = outDir - ? path.join(fedOptions.workspaceRoot, outDir) - : null; - memResults.add(outFiles.map((f) => new EsBuildResult(f, fullOutDir))); - }); + if (!existsSync(browserOutputPath)) { + mkdirSync(browserOutputPath, { recursive: true }); } - await buildForFederation(config, fedOptions, externals); - - options.deleteOutputPath = false; - - // TODO: Clarify how DevServer needs to be executed. Not sure if its right. - // TODO: Clarify if buildApplication is needed `executeDevServerBuilder` seems to choose the correct DevServer - - const appBuilderName = '@angular-devkit/build-angular:application'; - - const builderRun = nfOptions.dev - ? serveWithVite( - normOuterOptions, - appBuilderName, - _buildApplication, - context, - nfOptions.skipHtmlTransform - ? {} - : { indexHtml: transformIndexHtml(nfOptions) }, - { - buildPlugins: plugins, - middleware, - } - ) - : buildApplication(options, context, plugins); - - // builderRun.output.subscribe(async (output) => { - for await (const output of builderRun) { - lastResult = output; - - if (!output.success) { - setError('Compilation Error'); - reloadBrowser(); - continue; - } else { - setError(null); - } - - if (!write && output.outputFiles) { - memResults.add(output.outputFiles.map((file) => new EsBuildResult(file))); - } - - if (!write && output.assetFiles) { - memResults.add( - output.assetFiles.map((file) => new NgCliAssetResult(file)) - ); - } - - if (write && !nfOptions.dev && !nfOptions.skipHtmlTransform) { - updateIndexHtml(fedOptions, nfOptions); - } - - // if (first && runServer) { - // startServer(nfOptions, fedOptions.outputPath, memResults); - // } - - // if (!first && runServer) { - // reloadBrowser(); - // } - - if (!runServer) { - yield output; - } - - if (!first && nfOptions.dev) { - setTimeout(async () => { - // logger.info('Rebuilding federation artefacts ...'); - // await Promise.all([rebuildEvents.rebuild.emit()]); - await buildForFederation(config, fedOptions, externals); - logger.info('Done!'); + const fedData = await initFederationBuild(context.workspaceRoot, browserOutputPath, options.tsConfig); - if (runServer) { - setTimeout(() => reloadShell(nfOptions.shell), 0); - } - }, nfOptions.rebuildDelay); - } - - first = false; - } + const plugins: Plugin[] = [ + entryPointsPlugin(fedData.entries), + createSharedMappingsPlugin(fedData.sharedMappings), + externalsPlugin(fedData.externals), + ]; - yield lastResult || { success: false }; + return yield* buildApplication(options, context, {codePlugins: plugins, indexHtmlTransformer: transformIndexHtml()}); } // eslint-disable-next-line @typescript-eslint/no-explicit-any export default createBuilder(runBuilder) as any; -function infereConfigPath(tsConfig: string): string { - const relProjectPath = path.dirname(tsConfig); - const relConfigPath = path.join(relProjectPath, 'federation.config.js'); - - return relConfigPath; -} - -function transformIndexHtml( - nfOptions: NfBuilderSchema -): (content: string) => Promise { - return (content: string): Promise => - Promise.resolve( - updateScriptTags(content, 'main.js', 'polyfills.js', nfOptions) - ); -} - -function addDebugInformation(fileName: string, rawBody: string): string { - if (fileName !== '/remoteEntry.json') { - return rawBody; - } - - const remoteEntry = JSON.parse(rawBody) as FederationInfo; - const shared = remoteEntry.shared; - - if (!shared) { - return rawBody; - } - - const sharedForVite = shared.map((s) => ({ - ...s, - packageName: `/@id/${s.packageName}`, - })); - remoteEntry.shared = [...shared, ...sharedForVite]; - - return JSON.stringify(remoteEntry, null, 2); -} diff --git a/libs/native-federation/src/builders/serve/builder.ts b/libs/native-federation/src/builders/serve/builder.ts new file mode 100644 index 00000000..eeb0aba4 --- /dev/null +++ b/libs/native-federation/src/builders/serve/builder.ts @@ -0,0 +1,113 @@ +import { ExecutorContext, parseTargetString, readTargetOptions } from '@nx/devkit'; +import type { Plugin } from 'esbuild'; + +import { executeDevServer } from '@angular/build/src/builders/dev-server/'; +import { Schema } from '@angular/build/src/builders/dev-server/schema'; +import { eachValueFrom } from '@nx/devkit/src/utils/rxjs-for-await'; +import { FederationInfo } from '@softarc/native-federation-runtime'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; +import { lookup } from 'mrmime'; +import { extname, join } from 'path'; +import { Connect } from 'vite'; +import { entryPointsPlugin } from '../../utils/entry-points-plugin'; +import { externalsPlugin } from '../../utils/externals-plugin'; +import { initFederationBuild } from '../../utils/init-federation-build'; +import { createSharedMappingsPlugin } from '../../utils/shared-mappings-plugin'; +import { + BuilderContext, + BuilderOutput, + createBuilder, +} from '@angular-devkit/architect'; +import { transformIndexHtml } from '../../utils/updateIndexHtml'; + + +export async function* serveNativeFederation(options: Schema, context: BuilderContext) { + options.watch = true; + options.liveReload = true; + + const buildTarget = parseTargetString(options.buildTarget, context.projectGraph) + const buildOptions = readTargetOptions(buildTarget, context); + + const outdir = typeof buildOptions.outputPath === 'string' ? buildOptions.outputPath : buildOptions.outputPath.base; + const browserOutDir = outdir + '/browser'; + + if (existsSync(browserOutDir)) { + rmSync(browserOutDir, { recursive: true }); + } + + if (!existsSync(browserOutDir)) { + mkdirSync(browserOutDir, { recursive: true }); + } + + const fedData = await initFederationBuild(context.workspaceRoot, browserOutDir, buildOptions.tsConfig); + + const plugins: Plugin[] = [ + entryPointsPlugin(fedData.entries), + createSharedMappingsPlugin(fedData.sharedMappings), + externalsPlugin(fedData.externals), + ]; + + const middleware: Connect.NextHandleFunction[] = [ + returnRemoteEntryFromFs(context.workspaceRoot, browserOutDir), + ]; + + const ngDevServer = executeDevServer( + options, + context, + { indexHtml: transformIndexHtml() }, + { + middleware, + buildPlugins: plugins, + builderSelector: () => '@angular-devkit/build-angular:application', + } + ); + + return yield* eachValueFrom(ngDevServer); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default createBuilder(serveNativeFederation) as any; + +function returnRemoteEntryFromFs(workspaceRoot: string, browserOutDir: string): Connect.NextHandleFunction { + return (req, res, next) => { + const fileName = join( + workspaceRoot, + browserOutDir, + req.url + ); + const exists = existsSync(fileName); + + if (req.url === '/remoteEntry.json' && exists) { + const mimeType = lookup(extname(fileName)) || 'text/javascript'; + const rawBody = readFileSync(fileName, 'utf-8'); + const body = addDebugInformation(req.url, rawBody); + res.writeHead(200, { + 'Content-Type': mimeType, + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', + 'Access-Control-Allow-Headers': 'Content-Type', + }); + res.end(body); + } else { + next(); + } + } +} + +function addDebugInformation(fileName: string, rawBody: string): string { + const remoteEntry = JSON.parse(rawBody) as FederationInfo; + const shared = remoteEntry.shared; + + if (!shared) { + return rawBody; + } + + const sharedForVite = shared.map((s) => ({ + ...s, + packageName: `/@id/${s.packageName}`, + })); + + remoteEntry.shared = [...shared, ...sharedForVite]; + + return JSON.stringify(remoteEntry, null, 2); +} \ No newline at end of file diff --git a/libs/native-federation/src/utils/entry-points-plugin.ts b/libs/native-federation/src/utils/entry-points-plugin.ts new file mode 100644 index 00000000..8e518dbc --- /dev/null +++ b/libs/native-federation/src/utils/entry-points-plugin.ts @@ -0,0 +1,18 @@ +import type { Plugin, PluginBuild } from "esbuild"; + +/** + * add entries to bundler's entryPoints + * + * @param entries deps obj, which builder should handle as independent files + */ +export const entryPointsPlugin = (entries: Record): Plugin => { + return { + name: 'entry-points', + setup: (build: PluginBuild) => { + build.initialOptions.entryPoints = { + ...build.initialOptions.entryPoints, + ...entries, + } + } + } +} \ No newline at end of file diff --git a/libs/native-federation/src/utils/externals-plugin.ts b/libs/native-federation/src/utils/externals-plugin.ts new file mode 100644 index 00000000..f9ed3fb0 --- /dev/null +++ b/libs/native-federation/src/utils/externals-plugin.ts @@ -0,0 +1,14 @@ +import type { Plugin, PluginBuild } from "esbuild"; + +export const externalsPlugin = (externals: string[]): Plugin => { + return { + name: 'externals', + setup(build: PluginBuild) { + if (build.initialOptions.platform !== 'node') { + build.initialOptions.external = externals.filter( + (e) => e !== 'tslib' + ); + } + }, + } +} \ No newline at end of file diff --git a/libs/native-federation/src/utils/init-federation-build.ts b/libs/native-federation/src/utils/init-federation-build.ts new file mode 100644 index 00000000..c6cc36d2 --- /dev/null +++ b/libs/native-federation/src/utils/init-federation-build.ts @@ -0,0 +1,82 @@ +import { BuildAdapter, BuildHelperParams, FederationOptions, MappedPath, federationBuilder } from '@softarc/native-federation/build'; +import { dirname, join } from "path"; + +function infereConfigPath(tsConfig: string): string { + const relProjectPath = dirname(tsConfig); + const relConfigPath = join(relProjectPath, 'federation.config.js'); + + return relConfigPath; +} +/** + * @param entries external deps obj, adapter should fill + * @returns file names array. Native Federation would think they are already built and fill remoteEntry.json with their data + */ +const getMockAdapter = (entries: Record): BuildAdapter => { + return ({entryPoints}) => { + entryPoints.forEach(entry => { + // angular builder manage ext itself + // if not do this, output file would be soms line foo-bar.js.js + const name = entry.outName.replace('.js', ''); + + if (!entries[name]) { + entries[name] = entry.fileName; + } + }); + + const res = entryPoints.map(entry => ({fileName: entry.outName})); + + return Promise.resolve(res); + } +} + +interface InitFederationBuildData { + entries: Record; + sharedMappings: MappedPath[]; + externals: string[] +} + +/** + * initiating native federation for build + * + * 1. bundler should think {@link InitFederationBuildData.externals} are external deps and not include them into bundle + * 2. bundler should build every entry from {@link InitFederationBuildData.entries} as independent file + * + * @param root root dir (like contest.workspaceRoot) + * @param outputPath path to store remoteEntry.json (soms like dist//browser) + * @param tsConfPath path to application's tsconfig + * @returns all the federation data {@link InitFederationBuildData} + */ +export const initFederationBuild = async (root: string, outputPath: string, tsConfPath): Promise => { + // entries obj, it'll be filled by adapter + const fedEntries: Record = {}; + const mockAdapter: BuildAdapter = getMockAdapter(fedEntries); + + const fedOptions: FederationOptions = { + workspaceRoot: root, + outputPath: outputPath, + federationConfig: infereConfigPath(tsConfPath), + tsConfig: tsConfPath, + verbose: false, + watch: false, + dev: true, + }; + + const params: BuildHelperParams = { + options: fedOptions, + adapter: mockAdapter, + } + + + await federationBuilder.init(params); + + // after this line federationBuilder will call mockAdapter and fill remoteEntry.json with it's result + // 1. when fired, mockAdapter will fill fedEntries with file names and their pathes, builder should build + // 2. remoteEntry.json will store deps list with files names. Each file should contain it's dependency content. It's important for builder not to rename those files + await federationBuilder.build(); + + return { + entries: fedEntries, + sharedMappings: federationBuilder.config.sharedMappings, + externals: federationBuilder.externals, + }; +} \ No newline at end of file diff --git a/libs/native-federation/src/utils/updateIndexHtml.ts b/libs/native-federation/src/utils/updateIndexHtml.ts index d65c31c1..81f42079 100644 --- a/libs/native-federation/src/utils/updateIndexHtml.ts +++ b/libs/native-federation/src/utils/updateIndexHtml.ts @@ -3,10 +3,7 @@ import * as fs from 'fs'; import { FederationOptions } from '@softarc/native-federation/build'; import { NfBuilderSchema } from '../builders/build/schema'; -export function updateIndexHtml( - fedOptions: FederationOptions, - nfOptions: NfBuilderSchema -) { +export function updateIndexHtml(fedOptions: FederationOptions) { const outputPath = path.join(fedOptions.workspaceRoot, fedOptions.outputPath); const indexPath = path.join(outputPath, 'index.html'); const mainName = fs @@ -22,7 +19,6 @@ export function updateIndexHtml( indexContent, mainName, polyfillsName, - nfOptions ); fs.writeFileSync(indexPath, indexContent, 'utf-8'); } @@ -31,11 +27,9 @@ export function updateScriptTags( indexContent: string, mainName: string, polyfillsName: string, - nfOptions: NfBuilderSchema ) { const esmsOptions = { shimMode: true, - ...nfOptions.esmsInitOptions, }; const htmlFragment = ` @@ -53,3 +47,10 @@ export function updateScriptTags( indexContent = indexContent.replace('', `${htmlFragment}`); return indexContent; } + +export function transformIndexHtml(): (content: string) => Promise { + return (content: string): Promise => + Promise.resolve( + updateScriptTags(content, 'main.js', 'polyfills.js') + ); +} \ No newline at end of file