Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
},
"peerDependencies": {
"@softarc/native-federation": "^4.0.0",
"@softarc/native-federation-orchestrator": "^4.0.0",
"@softarc/native-federation-orchestrator": "^4.2.2",
"@angular-devkit/architect": ">=0.2102.0",
"@angular-devkit/build-angular": ">=21.2.0",
"@angular-devkit/core": ">=21.2.0",
"@angular/build": ">=21.2.0"
},
"dependencies": {
"@softarc/native-federation": "^4.0.0",
"@softarc/native-federation-orchestrator": "^4.0.0",
"@softarc/native-federation-orchestrator": "^4.2.2",
"@angular-devkit/architect": "^0.2102.0",
"@angular-devkit/build-angular": "^21.2.0",
"@angular-devkit/core": "^21.2.0",
Expand Down
83 changes: 73 additions & 10 deletions packages/angular/src/builders/build/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import {
} from '@softarc/native-federation/internal';
import { type Plugin, type PluginBuild } from 'esbuild';
import { existsSync, mkdirSync, rmSync } from 'fs';
import { fstart } from '../../tools/fstart-as-data-url.js';
import { federationServerEntry } from '../../tools/federation-server-entry.js';
import { generateDevHostInstancesEntry } from '../../tools/dev-host-instances-entry.js';
import { devHostInstancesPlugin } from '../../plugin/dev-host-instances-plugin.js';
import { createAngularBuildAdapter } from '../../utils/angular-esbuild-adapter.js';
import { getI18nConfig, translateFederationArtifacts } from '../../utils/i18n.js';
import { updateScriptTags } from '../../utils/update-index-html.js';
Expand Down Expand Up @@ -277,6 +279,22 @@ export async function* runBuilder(

const isLocalDevelopment = runViteServer && nfBuilderOptions.dev;

// Dev SSR: inject a bootstrap that inits federation and bridges the host's
// singletons to remotes. The plugin self-gates on platform === 'node', so
// it's a no-op for CSR dev servers. (Prod SSR uses writeFederationServerEntry.)
if (isLocalDevelopment) {
// The bridge fetches the manifest over HTTP from the dev server's origin
// (Vite never writes it to disk under `ng serve`).
const devServerOrigin = getDevServerOrigin(serverOptions);

plugins.push(
devHostInstancesPlugin(
generateDevHostInstancesEntry({ relBrowserPath: browserOutputPath, devServerOrigin }),
path.join(cachePath, 'nf-dev-host-instances.mjs')
)
);
}

// Initialize SSE reloader only for local development
if (isLocalDevelopment && nfBuilderOptions.buildNotifications?.enable) {
federationBuildNotifier.initialize(nfBuilderOptions.buildNotifications.endpoint);
Expand Down Expand Up @@ -357,10 +375,6 @@ export async function* runBuilder(
syncNfFileWatcher(nfWatcher, normalized.options.federationCache.bundlerCache);
}

if (activateSsr) {
writeFstartScript(normalized.options);
}

const hasLocales = i18n?.locales && Object.keys(i18n.locales).length > 0;
if (hasLocales && localeFilter) {
const start = process.hrtime();
Expand Down Expand Up @@ -502,6 +516,12 @@ export async function* runBuilder(
}
first = false;
}

// Rewrite the emitted SSR entry (see writeFederationServerEntry). After the
// Angular build so the entry it produced exists on disk.
if (activateSsr && ngBuildStatus.success) {
writeFederationServerEntry(normalized.options);
}
} finally {
rebuildQueue.dispose();
await adapter.dispose();
Expand All @@ -527,12 +547,55 @@ function removeBaseHref(req: { url?: string }, baseHref?: string) {
return url;
}

function writeFstartScript(nfOptions: NormalizedFederationOptions) {
/**
* Rename the CLI's emitted `server.mjs` to `bootstrap-server.mjs` and write the
* Angular-free {@link federationServerEntry} in its place, so the node loader is
* registered before any `@angular/*` is evaluated. See that file for the why.
*/
function writeFederationServerEntry(nfOptions: NormalizedFederationOptions) {
const serverOutpath = path.join(nfOptions.outputPath, '../server');
const fstartPath = path.join(serverOutpath, 'fstart.mjs');
const buffer = Buffer.from(fstart, 'base64');
fs.mkdirSync(serverOutpath, { recursive: true });
fs.writeFileSync(fstartPath, buffer, 'utf-8');
const emittedEntry = path.join(serverOutpath, 'server.mjs');
const bootstrapEntry = path.join(serverOutpath, 'bootstrap-server.mjs');

if (!fs.existsSync(emittedEntry)) {
logger.warn(
`SSR: expected '${emittedEntry}' was not found; skipping federation server entry. ` +
`Federated remotes may fail to render server-side.`
);
return;
}

fs.renameSync(emittedEntry, bootstrapEntry);

// Preserve the source map (if any) and repoint its reference.
const emittedMap = `${emittedEntry}.map`;
if (fs.existsSync(emittedMap)) {
const bootstrapMap = `${bootstrapEntry}.map`;
fs.renameSync(emittedMap, bootstrapMap);
const bootstrapCode = fs
.readFileSync(bootstrapEntry, 'utf-8')
.replace(/sourceMappingURL=server\.mjs\.map/g, 'sourceMappingURL=bootstrap-server.mjs.map');
fs.writeFileSync(bootstrapEntry, bootstrapCode, 'utf-8');
}

fs.writeFileSync(emittedEntry, federationServerEntry, 'utf-8');
}

/**
* Build the dev server's origin (e.g. `http://localhost:4200`) from the resolved
* dev-server options, whose `port` Angular's normalizeOptions already defaults.
* Omits the port when none is set, and returns undefined when there are no serve
* options at all, so the bridge falls back to the on-disk manifest path.
*/
function getDevServerOrigin(
serverOptions: { ssl?: boolean; host?: string; port?: number } | null
): string | undefined {
if (!serverOptions) {
return undefined;
}
const protocol = serverOptions.ssl ? 'https' : 'http';
const host = serverOptions.host || 'localhost';
return serverOptions.port ? `${protocol}://${host}:${serverOptions.port}` : `${protocol}://${host}`;
}

function getLocaleFilter(options: ApplicationBuilderOptions, runViteServer: boolean) {
Expand Down
41 changes: 41 additions & 0 deletions packages/angular/src/plugin/dev-host-instances-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Plugin } from 'esbuild';

/**
* Injects the dev-only host-instance bootstrap (see
* `tools/dev-host-instances-entry.ts`) into the `ng serve` SSR server bundle.
*
* Gates on `platform === 'node'` — the only reliable SSR signal here, since the
* serve target carries no `ssr` flag. CSR dev servers are a no-op.
*
* `inject` makes the bootstrap run before the app. The orchestrator's Node entry
* is kept external so its `module.register()` loader hook fires; bundled, the
* bridge would silently never run.
*
* @param bootstrapSource generated bootstrap module source.
* @param bootstrapFilePath absolute path to write it to (`inject` needs a file).
*/
export function devHostInstancesPlugin(bootstrapSource: string, bootstrapFilePath: string): Plugin {
return {
name: 'nf-dev-host-instances',
setup(build) {
const options = build.initialOptions;

if (options.platform !== 'node') {
return;
}

fs.mkdirSync(path.dirname(bootstrapFilePath), { recursive: true });
fs.writeFileSync(bootstrapFilePath, bootstrapSource, 'utf-8');

options.inject = [...(options.inject ?? []), bootstrapFilePath];

options.external = [
...(options.external ?? []),
'@softarc/native-federation-orchestrator/node',
'@softarc/native-federation-orchestrator',
];
},
};
}
4 changes: 3 additions & 1 deletion packages/angular/src/schematics/init/schematic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { updateWorkspaceConfig } from './steps/update-workspace-config.js';
import { addDependencies } from './steps/add-dependencies.js';
import { makeMainAsync } from './steps/make-main-async.js';
import { makeServerAsync } from './steps/make-server-async.js';
import { setServerRenderMode } from './steps/set-server-render-mode.js';
import { generateTsConfig } from './steps/generate-tsconfig.js';

export { updatePackageJson, patchAngularBuild } from './steps/update-package-json.js';
Expand Down Expand Up @@ -77,7 +78,8 @@ export default function config(options: NfSchematicSchema): Rule {
return chain([
generateRule,
makeMainAsync(main, options, remoteMap, manifestRelPath),
ssr ? makeServerAsync(server, options, remoteMap) : noop(),
ssr ? makeServerAsync(server, options) : noop(),
ssr ? setServerRenderMode(projectSourceRoot) : noop(),
]);
};
}
23 changes: 9 additions & 14 deletions packages/angular/src/schematics/init/steps/add-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
NodeDependencyType,
} from '@schematics/angular/utility/dependencies';

const SSR_VERSION = '4.0.0-RC9';

export function addDependencies(tree: Tree, context: SchematicContext, ssr: boolean): void {
addPackageJsonDependency(tree, {
name: '@angular-devkit/build-angular',
Expand All @@ -23,15 +21,17 @@ export function addDependencies(tree: Tree, context: SchematicContext, ssr: bool
overwrite: false,
});

// Browser-only projects bundle the orchestrator into the app, so a dev
// dependency suffices. For SSR it must be a runtime dependency: the generated
// server entry imports '@softarc/native-federation-orchestrator/node' as a
// bare specifier resolved from node_modules at runtime.
addPackageJsonDependency(tree, {
name: '@softarc/native-federation-orchestrator',
type: NodeDependencyType.Dev,
version: '^4.0.0',
overwrite: false,
type: ssr ? NodeDependencyType.Default : NodeDependencyType.Dev,
version: '^4.2.2',
overwrite: true,
});

context.addTask(new NodePackageInstallTask());

if (ssr) {
console.log('SSR detected ...');
console.log('Activating CORS ...');
Expand All @@ -42,12 +42,7 @@ export function addDependencies(tree: Tree, context: SchematicContext, ssr: bool
version: '^2.8.5',
overwrite: false,
});

addPackageJsonDependency(tree, {
name: '@softarc/native-federation-node',
type: NodeDependencyType.Default,
version: SSR_VERSION,
overwrite: true,
});
}

context.addTask(new NodePackageInstallTask());
}
97 changes: 26 additions & 71 deletions packages/angular/src/schematics/init/steps/make-server-async.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,44 @@
import type { Rule, Tree } from '@angular-devkit/schematics';
import type { NfSchematicSchema } from '../schema.js';
import * as path from 'path';

export function makeServerAsync(
server: string,
options: NfSchematicSchema,
remoteMap: unknown
): Rule {
/**
* Prepare an SSR project's `server.ts` for federated SSR. Federation is *not*
* initialised here (the build's generated entry does that — see
* `tools/federation-server-entry.ts`); this step only:
* - enables CORS (remotes are served from other origins), and
* - makes the server listen when imported (not main) by honouring `pm_id`,
* which the generated entry sets.
*/
export function makeServerAsync(server: string, options: NfSchematicSchema): Rule {
return async function (tree: Tree) {
const mainPath = path.dirname(server);
const bootstrapName = path.join(mainPath, 'bootstrap-server.ts');
const content = tree.read(server)?.toString('utf8');

if (tree.exists(bootstrapName)) {
console.info(`${bootstrapName} already exists.`);
if (!content) {
console.info(`${server} not found; skipping SSR server setup.`);
return;
}

const cors = `import { createRequire } from "module";
if (content.includes("process.env['pm_id']")) {
console.info(`${server} already prepared for federated SSR.`);
return;
}

const cors = `import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const cors = require("cors");
const cors = require('cors');
`;
const mainContent = tree.read(server)?.toString('utf8');
const updatedContent = (cors + mainContent)

const updatedContent = (cors + content)
.replace(
`const port = process.env['PORT'] || 4000`,
`const port = process.env['PORT'] || ${options.port || 4000}`
)
.replace(`const app = express();`, `const app = express();\n app.use(cors());`)
.replace(
`const app = express();`,
`const app = express();\n\tapp.use(cors());\n app.set('view engine', 'html');`
)
.replace(`if (isMainModule(import.meta.url)) {`, ``)
.replace(/\}(?![\s\S]*\})/, '');

tree.create(bootstrapName, updatedContent);

let newMainContent = '';
if (options.type === 'dynamic-host') {
newMainContent = `import { initNodeFederation } from '@softarc/native-federation-node';

console.log('Starting SSR for Shell');

(async () => {

await initNodeFederation({
remotesOrManifestUrl: '../browser/federation.manifest.json',
relBundlePath: '../browser/',
});

await import('./bootstrap-server');

})();
`;
} else if (options.type === 'host') {
const manifest = JSON.stringify(remoteMap, null, 2).replace(/"/g, "'");
newMainContent = `import { initNodeFederation } from '@softarc/native-federation-node';

console.log('Starting SSR for Shell');

(async () => {

await initNodeFederation({
remotesOrManifestUrl: ${manifest},
relBundlePath: '../browser/',
});

await import('./bootstrap-server');

})();
`;
} else {
newMainContent = `import { initNodeFederation } from '@softarc/native-federation-node';

(async () => {

await initNodeFederation({
relBundlePath: '../browser/'
});

await import('./bootstrap-server');

})();
`;
}
`if (isMainModule(import.meta.url)) {`,
`if (isMainModule(import.meta.url) || process.env['pm_id']) {`
);

tree.overwrite(server, newMainContent);
tree.overwrite(server, updatedContent);
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Rule, Tree } from '@angular-devkit/schematics';
import * as path from 'path';

/**
* Switch the scaffolded `RenderMode.Prerender` to `Server` in
* `app.routes.server.ts`. A federated remote loads at runtime and can't be
* prerendered, so the catch-all route must render on the server. Idempotent.
*/
export function setServerRenderMode(projectSourceRoot: string): Rule {
return async function (tree: Tree) {
const routesPath = path
.join(projectSourceRoot, 'app', 'app.routes.server.ts')
.replace(/\\/g, '/');

const content = tree.read(routesPath)?.toString('utf8');
if (!content) {
console.info(`${routesPath} not found; skipping render mode update.`);
return;
}

if (!content.includes('RenderMode.Prerender')) {
return;
}

tree.overwrite(routesPath, content.replace(/RenderMode\.Prerender/g, 'RenderMode.Server'));
};
}
Loading
Loading