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/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/src/bin/local-proxy.ts b/packages/microfrontends/src/bin/local-proxy.ts index ae4976a..879851b 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,40 @@ export class LocalProxy { proxyPort: number; router: ProxyRequestRouter; configFilePath?: string; + private sseClients = new Map>(); + private appReadyState = 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, + }; + } + 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 +509,41 @@ 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); + 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 +629,7 @@ export class LocalProxy { if (this.handleProxyInfoRequest(req.url, res)) { 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 +743,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, + path, + proxyPort: this.proxyPort, + applications: this.getApplicationsList(), + }), ); }); } else { @@ -711,7 +780,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 +803,199 @@ 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; } + private appCheckInterval: ReturnType | null = null; + + // 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; + + void 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 { + 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..684deb5 --- /dev/null +++ b/packages/microfrontends/src/html.d.ts @@ -0,0 +1,5 @@ +declare module '*.html' { + const content: string; + // eslint-disable-next-line import/no-default-export + 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/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, 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"],