From 603d42dea66b308aa6b8e517fa6477b86d307a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Wed, 3 Dec 2025 23:07:29 +0100 Subject: [PATCH 1/4] Local proxy waiting page --- packages/microfrontends/jest.config.ts | 5 + packages/microfrontends/package.json | 426 +++++------ .../microfrontends/src/bin/local-proxy.ts | 319 ++++++++- .../microfrontends/src/bin/waiting-page.html | 663 ++++++++++++++++++ .../microfrontends/src/bin/waiting-page.ts | 51 ++ packages/microfrontends/src/html.d.ts | 4 + .../microfrontends/test/html-transformer.cjs | 8 + packages/microfrontends/tsconfig.json | 1 + packages/microfrontends/tsup.config.ts | 3 + 9 files changed, 1256 insertions(+), 224 deletions(-) create mode 100644 packages/microfrontends/src/bin/waiting-page.html create mode 100644 packages/microfrontends/src/bin/waiting-page.ts create mode 100644 packages/microfrontends/src/html.d.ts create mode 100644 packages/microfrontends/test/html-transformer.cjs diff --git a/packages/microfrontends/jest.config.ts b/packages/microfrontends/jest.config.ts index 3373f66..c5873bf 100644 --- a/packages/microfrontends/jest.config.ts +++ b/packages/microfrontends/jest.config.ts @@ -33,6 +33,11 @@ export default async (): Promise => { transformIgnorePatterns: (config.transformIgnorePatterns ?? []).filter( (pattern) => !pattern.includes('node_modules'), ), + // Add HTML transformer while preserving existing transforms + transform: { + ...config.transform, + '\\.html$': '/test/html-transformer.cjs', + }, }; return finalConfig; }; diff --git a/packages/microfrontends/package.json b/packages/microfrontends/package.json index 17a38ef..8d2a8e8 100644 --- a/packages/microfrontends/package.json +++ b/packages/microfrontends/package.json @@ -1,215 +1,215 @@ { - "name": "@vercel/microfrontends", - "version": "2.2.0", - "private": false, - "description": "Defines configuration and utilities for microfrontends development", - "keywords": [ - "microfrontends", - "micro-frontends", - "micro frontends", - "microservices", - "Vercel", - "Next.js", - "React" - ], - "homepage": "https://vercel.com/docs/microfrontends", - "repository": { - "type": "git", - "url": "https://github.com/vercel/microfrontends.git", - "directory": "packages/microfrontends" - }, - "sideEffects": false, - "type": "module", - "exports": { - "./schema.json": "./schema/schema.json", - "./validation": { - "import": "./dist/validation.js", - "require": "./dist/validation.cjs" - }, - "./config": { - "import": "./dist/config.js", - "require": "./dist/config.cjs" - }, - "./experimental/sveltekit": { - "import": "./dist/experimental/sveltekit.js", - "require": "./dist/experimental/sveltekit.cjs" - }, - "./experimental/vite": { - "import": "./dist/experimental/vite.js", - "require": "./dist/experimental/vite.cjs" - }, - "./overrides": { - "import": "./dist/overrides.js", - "require": "./dist/overrides.cjs" - }, - "./microfrontends/server": { - "import": "./dist/microfrontends/server.js", - "require": "./dist/microfrontends/server.cjs" - }, - "./microfrontends/utils": { - "import": "./dist/microfrontends/utils.js", - "require": "./dist/microfrontends/utils.cjs" - }, - "./schema": { - "import": "./dist/schema.js", - "require": "./dist/schema.cjs" - }, - "./next/config": { - "import": "./dist/next/config.js", - "require": "./dist/next/config.cjs" - }, - "./next/middleware": { - "import": "./dist/next/middleware.js", - "require": "./dist/next/middleware.cjs" - }, - "./next/testing": { - "import": "./dist/next/testing.js", - "require": "./dist/next/testing.cjs" - }, - "./next/client": { - "import": "./dist/next/client.js", - "require": "./dist/next/client.cjs" - }, - "./utils/mfe-port": { - "import": "./dist/utils/mfe-port.js", - "require": "./dist/utils/mfe-port.cjs" - } - }, - "typesVersions": { - "*": { - "validation": [ - "./dist/validation.d.ts" - ], - "config": [ - "./dist/config.d.ts" - ], - "experimental/sveltekit": [ - "./dist/experimental/sveltekit.d.ts" - ], - "experimental/vite": [ - "./dist/experimental/vite.d.ts" - ], - "overrides": [ - "./dist/overrides.d.ts" - ], - "microfrontends/server": [ - "./dist/microfrontends/server.d.ts" - ], - "microfrontends/utils": [ - "./dist/microfrontends/utils.d.ts" - ], - "schema": [ - "./dist/schema.d.ts" - ], - "next/config": [ - "./dist/next/config.d.ts" - ], - "next/middleware": [ - "./dist/next/middleware.d.ts" - ], - "next/testing": [ - "./dist/next/testing.d.ts" - ], - "next/client": [ - "./dist/next/client.d.ts" - ], - "utils/mfe-port": [ - "./dist/utils/mfe-port.d.ts" - ] - } - }, - "bin": { - "microfrontends": "./cli/index.cjs" - }, - "files": [ - "dist", - "schema", - "CHANGELOG.md" - ], - "scripts": { - "build": "tsup", - "postbuild": "pnpm generate:exports", - "generate:exports": "tsx scripts/generate-exports/index.ts", - "generate:schema": "tsx scripts/generate-json-schema.ts", - "lint": "eslint .", - "lint-fix": "eslint . --fix", - "prepublishOnly": "pnpm build && pnpm generate:exports && pnpm generate:schema", - "proxy": "tsx src/proxy/index.ts", - "test": "cross-env TZ=UTC jest", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@next/env": "15.5.4", - "@types/md5": "^2.3.5", - "ajv": "^8.17.1", - "commander": "^12.1.0", - "cookie": "1.0.2", - "fast-glob": "^3.3.2", - "http-proxy": "^1.18.1", - "jsonc-parser": "^3.3.1", - "md5": "^2.3.0", - "nanoid": "^3.3.9", - "path-to-regexp": "6.2.1", - "semver": "^7.7.2" - }, - "devDependencies": { - "@edge-runtime/jest-environment": "^4.0.0", - "@edge-runtime/types": "^3.0.2", - "@sveltejs/kit": "2.17.2", - "@testing-library/react": "^15.0.7", - "@types/cookie": "0.5.1", - "@types/http-proxy": "^1.17.15", - "@types/jest": "^29.2.0", - "@types/json-schema": "^7.0.15", - "@types/node": "20.11.30", - "@types/react": "18.3.1", - "@types/react-dom": "18.3.0", - "@types/semver": "^7.7.0", - "eslint-config-custom": "workspace:*", - "jest": "^29.7.0", - "jest-environment-jsdom": "29.2.2", - "next": "15.5.4", - "react": "19.0.0", - "react-dom": "19.0.0", - "ts-config": "workspace:*", - "ts-json-schema-generator": "^1.1.2", - "ts-node": "~10.9.2", - "tsup": "^6.6.2", - "tsx": "^4.6.2", - "typescript": "5.7.3", - "vite": "5.4.11", - "webpack": "5" - }, - "peerDependencies": { - "@sveltejs/kit": ">=1", - "@vercel/analytics": ">=1.5.0", - "@vercel/speed-insights": ">=1.2.0", - "next": ">=13", - "react": ">=17.0.0", - "react-dom": ">=17.0.0", - "vite": ">=5" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - }, - "@vercel/analytics": { - "optional": true - }, - "@vercel/speed-insights": { - "optional": true - }, - "next": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "vite": { - "optional": true - } - } + "name": "@vercel/microfrontends", + "version": "2.2.0", + "private": false, + "description": "Defines configuration and utilities for microfrontends development", + "keywords": [ + "microfrontends", + "micro-frontends", + "micro frontends", + "microservices", + "Vercel", + "Next.js", + "React" + ], + "homepage": "https://vercel.com/docs/microfrontends", + "repository": { + "type": "git", + "url": "https://github.com/vercel/microfrontends.git", + "directory": "packages/microfrontends" + }, + "sideEffects": false, + "type": "module", + "exports": { + "./schema.json": "./schema/schema.json", + "./validation": { + "import": "./dist/validation.js", + "require": "./dist/validation.cjs" + }, + "./config": { + "import": "./dist/config.js", + "require": "./dist/config.cjs" + }, + "./experimental/sveltekit": { + "import": "./dist/experimental/sveltekit.js", + "require": "./dist/experimental/sveltekit.cjs" + }, + "./experimental/vite": { + "import": "./dist/experimental/vite.js", + "require": "./dist/experimental/vite.cjs" + }, + "./overrides": { + "import": "./dist/overrides.js", + "require": "./dist/overrides.cjs" + }, + "./microfrontends/server": { + "import": "./dist/microfrontends/server.js", + "require": "./dist/microfrontends/server.cjs" + }, + "./microfrontends/utils": { + "import": "./dist/microfrontends/utils.js", + "require": "./dist/microfrontends/utils.cjs" + }, + "./schema": { + "import": "./dist/schema.js", + "require": "./dist/schema.cjs" + }, + "./next/config": { + "import": "./dist/next/config.js", + "require": "./dist/next/config.cjs" + }, + "./next/middleware": { + "import": "./dist/next/middleware.js", + "require": "./dist/next/middleware.cjs" + }, + "./next/testing": { + "import": "./dist/next/testing.js", + "require": "./dist/next/testing.cjs" + }, + "./next/client": { + "import": "./dist/next/client.js", + "require": "./dist/next/client.cjs" + }, + "./utils/mfe-port": { + "import": "./dist/utils/mfe-port.js", + "require": "./dist/utils/mfe-port.cjs" + } + }, + "typesVersions": { + "*": { + "validation": [ + "./dist/validation.d.ts" + ], + "config": [ + "./dist/config.d.ts" + ], + "experimental/sveltekit": [ + "./dist/experimental/sveltekit.d.ts" + ], + "experimental/vite": [ + "./dist/experimental/vite.d.ts" + ], + "overrides": [ + "./dist/overrides.d.ts" + ], + "microfrontends/server": [ + "./dist/microfrontends/server.d.ts" + ], + "microfrontends/utils": [ + "./dist/microfrontends/utils.d.ts" + ], + "schema": [ + "./dist/schema.d.ts" + ], + "next/config": [ + "./dist/next/config.d.ts" + ], + "next/middleware": [ + "./dist/next/middleware.d.ts" + ], + "next/testing": [ + "./dist/next/testing.d.ts" + ], + "next/client": [ + "./dist/next/client.d.ts" + ], + "utils/mfe-port": [ + "./dist/utils/mfe-port.d.ts" + ] + } + }, + "bin": { + "microfrontends": "./cli/index.cjs" + }, + "files": [ + "dist", + "schema", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsup", + "postbuild": "pnpm generate:exports", + "generate:exports": "tsx scripts/generate-exports/index.ts", + "generate:schema": "tsx scripts/generate-json-schema.ts", + "lint": "eslint .", + "lint-fix": "eslint . --fix", + "prepublishOnly": "pnpm build && pnpm generate:exports && pnpm generate:schema", + "proxy": "tsx src/proxy/index.ts", + "test": "cross-env TZ=UTC jest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@next/env": "15.5.4", + "@types/md5": "^2.3.5", + "ajv": "^8.17.1", + "commander": "^12.1.0", + "cookie": "1.0.2", + "fast-glob": "^3.3.2", + "http-proxy": "^1.18.1", + "jsonc-parser": "^3.3.1", + "md5": "^2.3.0", + "nanoid": "^3.3.9", + "path-to-regexp": "6.2.1", + "semver": "^7.7.2" + }, + "devDependencies": { + "@edge-runtime/jest-environment": "^4.0.0", + "@edge-runtime/types": "^3.0.2", + "@sveltejs/kit": "2.17.2", + "@testing-library/react": "^15.0.7", + "@types/cookie": "0.5.1", + "@types/http-proxy": "^1.17.15", + "@types/jest": "^29.2.0", + "@types/json-schema": "^7.0.15", + "@types/node": "20.11.30", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", + "@types/semver": "^7.7.0", + "eslint-config-custom": "workspace:*", + "jest": "^29.7.0", + "jest-environment-jsdom": "29.2.2", + "next": "15.5.4", + "react": "19.0.0", + "react-dom": "19.0.0", + "ts-config": "workspace:*", + "ts-json-schema-generator": "^1.1.2", + "ts-node": "~10.9.2", + "tsup": "^6.6.2", + "tsx": "^4.6.2", + "typescript": "5.7.3", + "vite": "5.4.11", + "webpack": "5" + }, + "peerDependencies": { + "@sveltejs/kit": ">=1", + "@vercel/analytics": ">=1.5.0", + "@vercel/speed-insights": ">=1.2.0", + "next": ">=13", + "react": ">=17.0.0", + "react-dom": ">=17.0.0", + "vite": ">=5" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "@vercel/analytics": { + "optional": true + }, + "@vercel/speed-insights": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vite": { + "optional": true + } + } } diff --git a/packages/microfrontends/src/bin/local-proxy.ts b/packages/microfrontends/src/bin/local-proxy.ts index ae4976a..425e3cb 100644 --- a/packages/microfrontends/src/bin/local-proxy.ts +++ b/packages/microfrontends/src/bin/local-proxy.ts @@ -18,6 +18,7 @@ import { hashApplicationName } from '../config/microfrontends-config/isomorphic/ import cliPkg from '../../package.json'; import type { LocalProxyOptions, LocalProxyApplicationResponse } from './types'; import { localAuthHtml } from './local-auth'; +import { waitingPageHtml, type ApplicationInfo } from './waiting-page'; import { logger } from './logger'; // This is a header set to `1` by the local proxy on all outgoing requests to locally running applications. @@ -457,6 +458,41 @@ export class LocalProxy { proxyPort: number; router: ProxyRequestRouter; configFilePath?: string; + private sseClients: Map> = new Map(); + private appReadyState: Map = new Map(); + + private getApplicationsList(): ApplicationInfo[] { + const allApps = this.router.config.getAllApplications(); + const defaultApp = this.router.config.getDefaultApplication(); + const defaultFallback = defaultApp.fallback.host; + + return allApps.map((app) => { + const isLocal = Boolean( + this.router.localApps.find( + (name: string) => name === app.name || name === app.packageName, + ), + ); + + if (isLocal) { + return { + name: app.name, + port: app.development.local.port, + isLocal: true, + }; + } else { + const target = this.router.getApplicationTarget(app); + let fallbackHost = target.hostname; + if (!app.fallback) { + fallbackHost = defaultFallback; + } + return { + name: app.name, + isLocal: false, + fallback: fallbackHost, + }; + } + }); + } constructor( config: MicrofrontendConfigIsomorphic, @@ -474,18 +510,45 @@ export class LocalProxy { this.proxyPort = proxyPort ?? this.router.config.getLocalProxyPort(); this.configFilePath = configFilePath; this.proxy = Server.createProxyServer({ secure: true }); - this.proxy.on('error', (err, req, res) => { - if (res instanceof http.ServerResponse) { - res.writeHead(500, { - 'Content-Type': 'text/plain', - }); + + // Mark app as ready when proxy receives a successful response + this.proxy.on('proxyRes', (_proxyRes, req) => { + const target = this.router.getTarget(req); + // Skip if still in artificial startup delay + if (this.isInStartupDelay()) { + return; + } + if (target.isLocal && !this.appReadyState.get(target.application)) { + this.appReadyState.set(target.application, true); + this.notifyAppReady(target.application); + logger.debug(`App ${target.application} is now ready`); } + }); + this.proxy.on('error', (err, req, res) => { const target = this.router.getTarget(req); - res.end( - `Error proxying request to ${formatProxyTarget(target)}. Is the server running locally on port ${target.port}?`, - ); + // Mark app as not ready + this.appReadyState.set(target.application, false); + + if (res instanceof http.ServerResponse) { + res.writeHead(503, { + 'Content-Type': 'text/html; charset=utf-8', + }); + res.end( + waitingPageHtml({ + app: target.application, + port: target.port, + path: target.path, + proxyPort: this.proxyPort, + applications: this.getApplicationsList(), + }), + ); + } else { + res.end( + `Error proxying request to ${formatProxyTarget(target)}. Is the server running locally on port ${target.port}?`, + ); + } logger.error( `Error proxying request for ${formatProxyTarget(target)}: `, @@ -571,6 +634,27 @@ export class LocalProxy { if (this.handleProxyInfoRequest(req.url, res)) { return; } + + // Artificial delay for testing SSE - show waiting page during delay + if (this.isInStartupDelay()) { + const target = this.router.getTarget(req); + if (target.isLocal) { + res.writeHead(503, { + 'Content-Type': 'text/html; charset=utf-8', + }); + res.end( + waitingPageHtml({ + app: target.application, + port: target.port, + path: target.path, + proxyPort: this.proxyPort, + applications: this.getApplicationsList(), + }), + ); + return; + } + } + if (req.url?.includes('//')) { // If the URL contains '//', send a 307 redirect to the normalized URL, preserving all request headers const originalUrl = req.url; @@ -684,9 +768,19 @@ export class LocalProxy { req.pipe(proxyReq); proxyReq.on('error', (err) => { logger.error('Proxy request error: ', err); - res.writeHead(500, { 'Content-Type': 'text/plain' }); + + // Mark app as not ready + this.appReadyState.set(target.application, false); + + res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8' }); res.end( - `Error proxying request for ${target.application} to ${hostname}:${port}${path}`, + waitingPageHtml({ + app: target.application, + port: port, + path: path, + proxyPort: this.proxyPort, + applications: this.getApplicationsList(), + }), ); }); } else { @@ -711,7 +805,7 @@ export class LocalProxy { if (!path) { return false; } - const url = new URL(`http://example.comf${path}`); + const url = new URL(`http://example.com${path}`); const pathname = url.pathname; switch (pathname) { case '/.well-known/vercel/microfrontends/routing': { @@ -734,11 +828,214 @@ export class LocalProxy { res.end(JSON.stringify(payload)); return true; } + + case '/.well-known/vercel/microfrontends/app-ready-events': { + // Server-Sent Events endpoint for app ready notifications + const appName = url.searchParams.get('app'); + if (!appName) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing app parameter' })); + return true; + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + + // Add client to SSE clients list + if (!this.sseClients.has(appName)) { + this.sseClients.set(appName, new Set()); + } + this.sseClients.get(appName)!.add(res); + + // Start background monitoring for app readiness + this.startAppReadinessMonitor(); + + // Send initial connection message + res.write( + `data: ${JSON.stringify({ type: 'connected', app: appName })}\n\n`, + ); + + // Keep connection alive with heartbeat + const heartbeat = setInterval(() => { + res.write(`data: ${JSON.stringify({ type: 'heartbeat' })}\n\n`); + }, 30000); + + // Cleanup on close + res.on('close', () => { + clearInterval(heartbeat); + this.sseClients.get(appName)?.delete(res); + + // Stop monitor if no more clients + let hasClients = false; + for (const clients of this.sseClients.values()) { + if (clients.size > 0) { + hasClients = true; + break; + } + } + if (!hasClients) { + this.stopAppReadinessMonitor(); + } + }); + + return true; + } + + case '/.well-known/vercel/microfrontends/app-status': { + // Endpoint to check if an app is ready (responds to HTTP request) + const appName = url.searchParams.get('app'); + if (!appName) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing app parameter' })); + return true; + } + + // Check if the app is ready by attempting to connect + const app = this.router.config + .getAllApplications() + .find((a) => a.name === appName || a.packageName === appName); + + if (!app) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'App not found', ready: false })); + return true; + } + + const target = this.router.getApplicationTarget(app); + + // Only check local apps + if (!target.isLocal) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ready: true, app: appName })); + return true; + } + + // Check if the port is accessible + this.checkAppReady(target) + .then((ready) => { + if (ready && !this.appReadyState.get(appName)) { + // App just became ready, notify SSE clients + this.appReadyState.set(appName, true); + this.notifyAppReady(appName); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ready, app: appName })); + }) + .catch(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ready: false, app: appName })); + }); + + return true; + } } return false; } + // Track startup time for artificial delay (for testing SSE) + private startupTime = Date.now(); + // Set MFE_STARTUP_DELAY env var to add artificial delay in ms (e.g., MFE_STARTUP_DELAY=10000 for 10 seconds) + private startupDelay = parseInt(process.env.MFE_STARTUP_DELAY || '0', 10); + private appCheckInterval: ReturnType | null = null; + + private isInStartupDelay(): boolean { + return ( + this.startupDelay > 0 && Date.now() - this.startupTime < this.startupDelay + ); + } + + // Start background monitoring for local apps that SSE clients are waiting for + private startAppReadinessMonitor(): void { + if (this.appCheckInterval) return; + + this.appCheckInterval = setInterval(() => { + // Check each app that has SSE clients waiting + for (const [appName, clients] of this.sseClients.entries()) { + if (clients.size === 0) continue; + if (this.appReadyState.get(appName)) continue; + + // Find the app and check if it's ready + const app = this.router.config + .getAllApplications() + .find((a) => a.name === appName || a.packageName === appName); + + if (!app) continue; + + const target = this.router.getApplicationTarget(app); + if (!target.isLocal) continue; + + this.checkAppReady(target).then((ready) => { + if (ready && !this.appReadyState.get(appName)) { + this.appReadyState.set(appName, true); + this.notifyAppReady(appName); + logger.debug(`App ${appName} is now ready (background check)`); + } + }); + } + }, 1000); + } + + private stopAppReadinessMonitor(): void { + if (this.appCheckInterval) { + clearInterval(this.appCheckInterval); + this.appCheckInterval = null; + } + } + + private checkAppReady(target: ProxyTarget): Promise { + // Artificial delay for testing SSE functionality + if (this.isInStartupDelay()) { + return Promise.resolve(false); + } + + return new Promise((resolve) => { + const req = http.request( + { + hostname: target.hostname, + port: target.port, + path: '/', + method: 'HEAD', + timeout: 2000, + }, + (res) => { + res.destroy(); + resolve(true); + }, + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); + } + + private notifyAppReady(appName: string): void { + const clients = this.sseClients.get(appName); + if (!clients) return; + + const message = `data: ${JSON.stringify({ type: 'ready', app: appName })}\n\n`; + for (const client of clients) { + try { + client.write(message); + } catch { + // Client may have disconnected + clients.delete(client); + } + } + } + private displayStartupMessage(): void { const allApps = this.router.config.getAllApplications(); const localApps: { name: string; port?: number }[] = []; diff --git a/packages/microfrontends/src/bin/waiting-page.html b/packages/microfrontends/src/bin/waiting-page.html new file mode 100644 index 0000000..d47afbc --- /dev/null +++ b/packages/microfrontends/src/bin/waiting-page.html @@ -0,0 +1,663 @@ + + + + + + + Microfrontends + + + + + + + + +
+
+ + +

+ The proxy is ready, but the upstream applications are still starting + up. +

+ +
+
+
+ Waiting for applications to start... +
+ +
+
+ Application + +
+
+ Expected Port + +
+
+ Path + +
+
+
+ +
+
Applications
+
+
+ +
+
💡 Tip
+ +
+ +
+ + Listening for updates... +
+
+ + +
+ + + + + diff --git a/packages/microfrontends/src/bin/waiting-page.ts b/packages/microfrontends/src/bin/waiting-page.ts new file mode 100644 index 0000000..19717c8 --- /dev/null +++ b/packages/microfrontends/src/bin/waiting-page.ts @@ -0,0 +1,51 @@ +import waitingPageTemplate from './waiting-page.html'; + +export interface ApplicationInfo { + name: string; + port?: number; + isLocal: boolean; + fallback?: string; +} + +/** + * Returns the HTML for the waiting page shown when upstream apps are not yet ready. + * Features Vercel-themed styling and auto-reload when apps become available. + */ +export const waitingPageHtml = ({ + app, + port, + path, + proxyPort, + applications, +}: { + app: string; + port?: number; + path?: string; + proxyPort: number; + applications?: ApplicationInfo[]; +}): string => { + const displayPath = path ?? '/'; + const appList = applications ?? []; + + return waitingPageTemplate + .replace( + "var __MFE_APP__ = '';", + `var __MFE_APP__ = ${JSON.stringify(app)};`, + ) + .replace( + 'var __MFE_PORT__ = null;', + `var __MFE_PORT__ = ${port ?? 'null'};`, + ) + .replace( + "var __MFE_PATH__ = '/';", + `var __MFE_PATH__ = ${JSON.stringify(displayPath)};`, + ) + .replace( + 'var __MFE_PROXY_PORT__ = 3000;', + `var __MFE_PROXY_PORT__ = ${proxyPort};`, + ) + .replace( + 'var __MFE_APPLICATIONS__ = [];', + `var __MFE_APPLICATIONS__ = ${JSON.stringify(appList)};`, + ); +}; diff --git a/packages/microfrontends/src/html.d.ts b/packages/microfrontends/src/html.d.ts new file mode 100644 index 0000000..448f7d1 --- /dev/null +++ b/packages/microfrontends/src/html.d.ts @@ -0,0 +1,4 @@ +declare module '*.html' { + const content: string; + export default content; +} diff --git a/packages/microfrontends/test/html-transformer.cjs b/packages/microfrontends/test/html-transformer.cjs new file mode 100644 index 0000000..505c60e --- /dev/null +++ b/packages/microfrontends/test/html-transformer.cjs @@ -0,0 +1,8 @@ +// Jest transformer for HTML files - exports the file content as a string +module.exports = { + process(sourceText) { + return { + code: `module.exports = ${JSON.stringify(sourceText)};`, + }; + }, +}; diff --git a/packages/microfrontends/tsconfig.json b/packages/microfrontends/tsconfig.json index 3fea518..3b2d3fc 100644 --- a/packages/microfrontends/tsconfig.json +++ b/packages/microfrontends/tsconfig.json @@ -6,5 +6,6 @@ "jsx": "react-jsx", "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, + "include": ["src"], "exclude": ["node_modules", "dist", "examples"] } diff --git a/packages/microfrontends/tsup.config.ts b/packages/microfrontends/tsup.config.ts index 334d2f1..307e113 100644 --- a/packages/microfrontends/tsup.config.ts +++ b/packages/microfrontends/tsup.config.ts @@ -49,6 +49,9 @@ export default defineConfig([ entry: { 'bin/cli': 'src/bin/index.ts', }, + loader: { + '.html': 'text', + }, }, { ...COMMON_CFG, From b708c3c39fd4fb89186380a801d86ca07514923e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Wed, 3 Dec 2025 23:18:32 +0100 Subject: [PATCH 2/4] fix lint --- .../microfrontends/src/bin/local-proxy.ts | 33 +++++++++---------- packages/microfrontends/src/html.d.ts | 1 + packages/microfrontends/tsconfig.json | 1 - 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/microfrontends/src/bin/local-proxy.ts b/packages/microfrontends/src/bin/local-proxy.ts index 425e3cb..3e2892c 100644 --- a/packages/microfrontends/src/bin/local-proxy.ts +++ b/packages/microfrontends/src/bin/local-proxy.ts @@ -458,8 +458,8 @@ export class LocalProxy { proxyPort: number; router: ProxyRequestRouter; configFilePath?: string; - private sseClients: Map> = new Map(); - private appReadyState: Map = new Map(); + private sseClients = new Map>(); + private appReadyState = new Map(); private getApplicationsList(): ApplicationInfo[] { const allApps = this.router.config.getAllApplications(); @@ -479,18 +479,17 @@ export class LocalProxy { port: app.development.local.port, isLocal: true, }; - } else { - const target = this.router.getApplicationTarget(app); - let fallbackHost = target.hostname; - if (!app.fallback) { - fallbackHost = defaultFallback; - } - return { - name: app.name, - isLocal: false, - fallback: fallbackHost, - }; } + const target = this.router.getApplicationTarget(app); + let fallbackHost = target.hostname; + if (!app.fallback) { + fallbackHost = defaultFallback; + } + return { + name: app.name, + isLocal: false, + fallback: fallbackHost, + }; }); } @@ -776,8 +775,8 @@ export class LocalProxy { res.end( waitingPageHtml({ app: target.application, - port: port, - path: path, + port, + path, proxyPort: this.proxyPort, applications: this.getApplicationsList(), }), @@ -849,7 +848,7 @@ export class LocalProxy { if (!this.sseClients.has(appName)) { this.sseClients.set(appName, new Set()); } - this.sseClients.get(appName)!.add(res); + this.sseClients.get(appName)?.add(res); // Start background monitoring for app readiness this.startAppReadinessMonitor(); @@ -969,7 +968,7 @@ export class LocalProxy { const target = this.router.getApplicationTarget(app); if (!target.isLocal) continue; - this.checkAppReady(target).then((ready) => { + void this.checkAppReady(target).then((ready) => { if (ready && !this.appReadyState.get(appName)) { this.appReadyState.set(appName, true); this.notifyAppReady(appName); diff --git a/packages/microfrontends/src/html.d.ts b/packages/microfrontends/src/html.d.ts index 448f7d1..684deb5 100644 --- a/packages/microfrontends/src/html.d.ts +++ b/packages/microfrontends/src/html.d.ts @@ -1,4 +1,5 @@ declare module '*.html' { const content: string; + // eslint-disable-next-line import/no-default-export export default content; } diff --git a/packages/microfrontends/tsconfig.json b/packages/microfrontends/tsconfig.json index 3b2d3fc..3fea518 100644 --- a/packages/microfrontends/tsconfig.json +++ b/packages/microfrontends/tsconfig.json @@ -6,6 +6,5 @@ "jsx": "react-jsx", "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, - "include": ["src"], "exclude": ["node_modules", "dist", "examples"] } From b3dafee654b35fe368aedff3b4a7b14f34b32844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Wed, 3 Dec 2025 23:20:07 +0100 Subject: [PATCH 3/4] package.json --- packages/microfrontends/package.json | 426 +++++++++++++-------------- 1 file changed, 213 insertions(+), 213 deletions(-) diff --git a/packages/microfrontends/package.json b/packages/microfrontends/package.json index 8d2a8e8..17a38ef 100644 --- a/packages/microfrontends/package.json +++ b/packages/microfrontends/package.json @@ -1,215 +1,215 @@ { - "name": "@vercel/microfrontends", - "version": "2.2.0", - "private": false, - "description": "Defines configuration and utilities for microfrontends development", - "keywords": [ - "microfrontends", - "micro-frontends", - "micro frontends", - "microservices", - "Vercel", - "Next.js", - "React" - ], - "homepage": "https://vercel.com/docs/microfrontends", - "repository": { - "type": "git", - "url": "https://github.com/vercel/microfrontends.git", - "directory": "packages/microfrontends" - }, - "sideEffects": false, - "type": "module", - "exports": { - "./schema.json": "./schema/schema.json", - "./validation": { - "import": "./dist/validation.js", - "require": "./dist/validation.cjs" - }, - "./config": { - "import": "./dist/config.js", - "require": "./dist/config.cjs" - }, - "./experimental/sveltekit": { - "import": "./dist/experimental/sveltekit.js", - "require": "./dist/experimental/sveltekit.cjs" - }, - "./experimental/vite": { - "import": "./dist/experimental/vite.js", - "require": "./dist/experimental/vite.cjs" - }, - "./overrides": { - "import": "./dist/overrides.js", - "require": "./dist/overrides.cjs" - }, - "./microfrontends/server": { - "import": "./dist/microfrontends/server.js", - "require": "./dist/microfrontends/server.cjs" - }, - "./microfrontends/utils": { - "import": "./dist/microfrontends/utils.js", - "require": "./dist/microfrontends/utils.cjs" - }, - "./schema": { - "import": "./dist/schema.js", - "require": "./dist/schema.cjs" - }, - "./next/config": { - "import": "./dist/next/config.js", - "require": "./dist/next/config.cjs" - }, - "./next/middleware": { - "import": "./dist/next/middleware.js", - "require": "./dist/next/middleware.cjs" - }, - "./next/testing": { - "import": "./dist/next/testing.js", - "require": "./dist/next/testing.cjs" - }, - "./next/client": { - "import": "./dist/next/client.js", - "require": "./dist/next/client.cjs" - }, - "./utils/mfe-port": { - "import": "./dist/utils/mfe-port.js", - "require": "./dist/utils/mfe-port.cjs" - } - }, - "typesVersions": { - "*": { - "validation": [ - "./dist/validation.d.ts" - ], - "config": [ - "./dist/config.d.ts" - ], - "experimental/sveltekit": [ - "./dist/experimental/sveltekit.d.ts" - ], - "experimental/vite": [ - "./dist/experimental/vite.d.ts" - ], - "overrides": [ - "./dist/overrides.d.ts" - ], - "microfrontends/server": [ - "./dist/microfrontends/server.d.ts" - ], - "microfrontends/utils": [ - "./dist/microfrontends/utils.d.ts" - ], - "schema": [ - "./dist/schema.d.ts" - ], - "next/config": [ - "./dist/next/config.d.ts" - ], - "next/middleware": [ - "./dist/next/middleware.d.ts" - ], - "next/testing": [ - "./dist/next/testing.d.ts" - ], - "next/client": [ - "./dist/next/client.d.ts" - ], - "utils/mfe-port": [ - "./dist/utils/mfe-port.d.ts" - ] - } - }, - "bin": { - "microfrontends": "./cli/index.cjs" - }, - "files": [ - "dist", - "schema", - "CHANGELOG.md" - ], - "scripts": { - "build": "tsup", - "postbuild": "pnpm generate:exports", - "generate:exports": "tsx scripts/generate-exports/index.ts", - "generate:schema": "tsx scripts/generate-json-schema.ts", - "lint": "eslint .", - "lint-fix": "eslint . --fix", - "prepublishOnly": "pnpm build && pnpm generate:exports && pnpm generate:schema", - "proxy": "tsx src/proxy/index.ts", - "test": "cross-env TZ=UTC jest", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@next/env": "15.5.4", - "@types/md5": "^2.3.5", - "ajv": "^8.17.1", - "commander": "^12.1.0", - "cookie": "1.0.2", - "fast-glob": "^3.3.2", - "http-proxy": "^1.18.1", - "jsonc-parser": "^3.3.1", - "md5": "^2.3.0", - "nanoid": "^3.3.9", - "path-to-regexp": "6.2.1", - "semver": "^7.7.2" - }, - "devDependencies": { - "@edge-runtime/jest-environment": "^4.0.0", - "@edge-runtime/types": "^3.0.2", - "@sveltejs/kit": "2.17.2", - "@testing-library/react": "^15.0.7", - "@types/cookie": "0.5.1", - "@types/http-proxy": "^1.17.15", - "@types/jest": "^29.2.0", - "@types/json-schema": "^7.0.15", - "@types/node": "20.11.30", - "@types/react": "18.3.1", - "@types/react-dom": "18.3.0", - "@types/semver": "^7.7.0", - "eslint-config-custom": "workspace:*", - "jest": "^29.7.0", - "jest-environment-jsdom": "29.2.2", - "next": "15.5.4", - "react": "19.0.0", - "react-dom": "19.0.0", - "ts-config": "workspace:*", - "ts-json-schema-generator": "^1.1.2", - "ts-node": "~10.9.2", - "tsup": "^6.6.2", - "tsx": "^4.6.2", - "typescript": "5.7.3", - "vite": "5.4.11", - "webpack": "5" - }, - "peerDependencies": { - "@sveltejs/kit": ">=1", - "@vercel/analytics": ">=1.5.0", - "@vercel/speed-insights": ">=1.2.0", - "next": ">=13", - "react": ">=17.0.0", - "react-dom": ">=17.0.0", - "vite": ">=5" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - }, - "@vercel/analytics": { - "optional": true - }, - "@vercel/speed-insights": { - "optional": true - }, - "next": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "vite": { - "optional": true - } - } + "name": "@vercel/microfrontends", + "version": "2.2.0", + "private": false, + "description": "Defines configuration and utilities for microfrontends development", + "keywords": [ + "microfrontends", + "micro-frontends", + "micro frontends", + "microservices", + "Vercel", + "Next.js", + "React" + ], + "homepage": "https://vercel.com/docs/microfrontends", + "repository": { + "type": "git", + "url": "https://github.com/vercel/microfrontends.git", + "directory": "packages/microfrontends" + }, + "sideEffects": false, + "type": "module", + "exports": { + "./schema.json": "./schema/schema.json", + "./validation": { + "import": "./dist/validation.js", + "require": "./dist/validation.cjs" + }, + "./config": { + "import": "./dist/config.js", + "require": "./dist/config.cjs" + }, + "./experimental/sveltekit": { + "import": "./dist/experimental/sveltekit.js", + "require": "./dist/experimental/sveltekit.cjs" + }, + "./experimental/vite": { + "import": "./dist/experimental/vite.js", + "require": "./dist/experimental/vite.cjs" + }, + "./overrides": { + "import": "./dist/overrides.js", + "require": "./dist/overrides.cjs" + }, + "./microfrontends/server": { + "import": "./dist/microfrontends/server.js", + "require": "./dist/microfrontends/server.cjs" + }, + "./microfrontends/utils": { + "import": "./dist/microfrontends/utils.js", + "require": "./dist/microfrontends/utils.cjs" + }, + "./schema": { + "import": "./dist/schema.js", + "require": "./dist/schema.cjs" + }, + "./next/config": { + "import": "./dist/next/config.js", + "require": "./dist/next/config.cjs" + }, + "./next/middleware": { + "import": "./dist/next/middleware.js", + "require": "./dist/next/middleware.cjs" + }, + "./next/testing": { + "import": "./dist/next/testing.js", + "require": "./dist/next/testing.cjs" + }, + "./next/client": { + "import": "./dist/next/client.js", + "require": "./dist/next/client.cjs" + }, + "./utils/mfe-port": { + "import": "./dist/utils/mfe-port.js", + "require": "./dist/utils/mfe-port.cjs" + } + }, + "typesVersions": { + "*": { + "validation": [ + "./dist/validation.d.ts" + ], + "config": [ + "./dist/config.d.ts" + ], + "experimental/sveltekit": [ + "./dist/experimental/sveltekit.d.ts" + ], + "experimental/vite": [ + "./dist/experimental/vite.d.ts" + ], + "overrides": [ + "./dist/overrides.d.ts" + ], + "microfrontends/server": [ + "./dist/microfrontends/server.d.ts" + ], + "microfrontends/utils": [ + "./dist/microfrontends/utils.d.ts" + ], + "schema": [ + "./dist/schema.d.ts" + ], + "next/config": [ + "./dist/next/config.d.ts" + ], + "next/middleware": [ + "./dist/next/middleware.d.ts" + ], + "next/testing": [ + "./dist/next/testing.d.ts" + ], + "next/client": [ + "./dist/next/client.d.ts" + ], + "utils/mfe-port": [ + "./dist/utils/mfe-port.d.ts" + ] + } + }, + "bin": { + "microfrontends": "./cli/index.cjs" + }, + "files": [ + "dist", + "schema", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsup", + "postbuild": "pnpm generate:exports", + "generate:exports": "tsx scripts/generate-exports/index.ts", + "generate:schema": "tsx scripts/generate-json-schema.ts", + "lint": "eslint .", + "lint-fix": "eslint . --fix", + "prepublishOnly": "pnpm build && pnpm generate:exports && pnpm generate:schema", + "proxy": "tsx src/proxy/index.ts", + "test": "cross-env TZ=UTC jest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@next/env": "15.5.4", + "@types/md5": "^2.3.5", + "ajv": "^8.17.1", + "commander": "^12.1.0", + "cookie": "1.0.2", + "fast-glob": "^3.3.2", + "http-proxy": "^1.18.1", + "jsonc-parser": "^3.3.1", + "md5": "^2.3.0", + "nanoid": "^3.3.9", + "path-to-regexp": "6.2.1", + "semver": "^7.7.2" + }, + "devDependencies": { + "@edge-runtime/jest-environment": "^4.0.0", + "@edge-runtime/types": "^3.0.2", + "@sveltejs/kit": "2.17.2", + "@testing-library/react": "^15.0.7", + "@types/cookie": "0.5.1", + "@types/http-proxy": "^1.17.15", + "@types/jest": "^29.2.0", + "@types/json-schema": "^7.0.15", + "@types/node": "20.11.30", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", + "@types/semver": "^7.7.0", + "eslint-config-custom": "workspace:*", + "jest": "^29.7.0", + "jest-environment-jsdom": "29.2.2", + "next": "15.5.4", + "react": "19.0.0", + "react-dom": "19.0.0", + "ts-config": "workspace:*", + "ts-json-schema-generator": "^1.1.2", + "ts-node": "~10.9.2", + "tsup": "^6.6.2", + "tsx": "^4.6.2", + "typescript": "5.7.3", + "vite": "5.4.11", + "webpack": "5" + }, + "peerDependencies": { + "@sveltejs/kit": ">=1", + "@vercel/analytics": ">=1.5.0", + "@vercel/speed-insights": ">=1.2.0", + "next": ">=13", + "react": ">=17.0.0", + "react-dom": ">=17.0.0", + "vite": ">=5" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "@vercel/analytics": { + "optional": true + }, + "@vercel/speed-insights": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vite": { + "optional": true + } + } } From 9633a54622d0387408a353d165da5b4221df4be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Thu, 4 Dec 2025 10:37:17 +0100 Subject: [PATCH 4/4] cleanup debug delay --- examples/nextjs-app/marketing/package.json | 2 +- .../marketing/scripts/delayed-dev.mjs | 28 +++++++++++++ .../microfrontends/src/bin/local-proxy.ts | 39 ------------------- turbo.json | 6 ++- 4 files changed, 34 insertions(+), 41 deletions(-) create mode 100644 examples/nextjs-app/marketing/scripts/delayed-dev.mjs diff --git a/examples/nextjs-app/marketing/package.json b/examples/nextjs-app/marketing/package.json index f3783a2..976b780 100644 --- a/examples/nextjs-app/marketing/package.json +++ b/examples/nextjs-app/marketing/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "build": "next build", - "dev": "next dev --turbo --port $(microfrontends port)", + "dev": "PORT=$(microfrontends port) node scripts/delayed-dev.mjs", "lint": "next typegen && eslint .", "lint-fix": "eslint . --fix", "start": "next start", diff --git a/examples/nextjs-app/marketing/scripts/delayed-dev.mjs b/examples/nextjs-app/marketing/scripts/delayed-dev.mjs new file mode 100644 index 0000000..a0efe31 --- /dev/null +++ b/examples/nextjs-app/marketing/scripts/delayed-dev.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; + +const delay = parseInt(process.env.MFE_STARTUP_DELAY || '0', 10) * 1000; + +if (delay > 0) { + // eslint-disable-next-line no-console + console.log(`⏳ Delaying startup by ${delay / 1000} seconds...`); +} + +setTimeout(() => { + if (delay > 0) { + // eslint-disable-next-line no-console + console.log('🚀 Starting Next.js dev server...'); + } + const child = spawn( + 'next', + ['dev', '--turbo', '--port', process.env.PORT || '3000'], + { + stdio: 'inherit', + shell: true, + }, + ); + + child.on('exit', (code) => { + process.exit(code || 0); + }); +}, delay); diff --git a/packages/microfrontends/src/bin/local-proxy.ts b/packages/microfrontends/src/bin/local-proxy.ts index 3e2892c..879851b 100644 --- a/packages/microfrontends/src/bin/local-proxy.ts +++ b/packages/microfrontends/src/bin/local-proxy.ts @@ -513,10 +513,6 @@ export class LocalProxy { // Mark app as ready when proxy receives a successful response this.proxy.on('proxyRes', (_proxyRes, req) => { const target = this.router.getTarget(req); - // Skip if still in artificial startup delay - if (this.isInStartupDelay()) { - return; - } if (target.isLocal && !this.appReadyState.get(target.application)) { this.appReadyState.set(target.application, true); this.notifyAppReady(target.application); @@ -634,26 +630,6 @@ export class LocalProxy { return; } - // Artificial delay for testing SSE - show waiting page during delay - if (this.isInStartupDelay()) { - const target = this.router.getTarget(req); - if (target.isLocal) { - res.writeHead(503, { - 'Content-Type': 'text/html; charset=utf-8', - }); - res.end( - waitingPageHtml({ - app: target.application, - port: target.port, - path: target.path, - proxyPort: this.proxyPort, - applications: this.getApplicationsList(), - }), - ); - return; - } - } - if (req.url?.includes('//')) { // If the URL contains '//', send a 307 redirect to the normalized URL, preserving all request headers const originalUrl = req.url; @@ -936,18 +912,8 @@ export class LocalProxy { return false; } - // Track startup time for artificial delay (for testing SSE) - private startupTime = Date.now(); - // Set MFE_STARTUP_DELAY env var to add artificial delay in ms (e.g., MFE_STARTUP_DELAY=10000 for 10 seconds) - private startupDelay = parseInt(process.env.MFE_STARTUP_DELAY || '0', 10); private appCheckInterval: ReturnType | null = null; - private isInStartupDelay(): boolean { - return ( - this.startupDelay > 0 && Date.now() - this.startupTime < this.startupDelay - ); - } - // Start background monitoring for local apps that SSE clients are waiting for private startAppReadinessMonitor(): void { if (this.appCheckInterval) return; @@ -987,11 +953,6 @@ export class LocalProxy { } private checkAppReady(target: ProxyTarget): Promise { - // Artificial delay for testing SSE functionality - if (this.isInStartupDelay()) { - return Promise.resolve(false); - } - return new Promise((resolve) => { const req = http.request( { diff --git a/turbo.json b/turbo.json index 66c1d4d..9865808 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,10 @@ { "$schema": "https://turborepo.org/schema.json", - "globalPassThroughEnv": ["MFE_DEBUG", "VC_MICROFRONTENDS_CONFIG_FILE_NAME"], + "globalPassThroughEnv": [ + "MFE_DEBUG", + "MFE_STARTUP_DELAY", + "VC_MICROFRONTENDS_CONFIG_FILE_NAME" + ], "tasks": { "build": { "dependsOn": ["^build"],