create-interview-cockpit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3242 @@
1
+ import {
2
+ cloneFrontendLabWorkspace,
3
+ DEFAULT_MODULE_FEDERATION_LAB,
4
+ } from "./reactLab";
5
+
6
+ export interface BrowserSecurityTemplate {
7
+ id:
8
+ | "csp"
9
+ | "xss"
10
+ | "csrf"
11
+ | "mfe-csp-xss"
12
+ | "mfe-build-serve"
13
+ | "mfe-ssr-nextjs"
14
+ | "mfe-telemetry";
15
+ label: string;
16
+ description: string;
17
+ serverCode: string;
18
+ clientCode: string;
19
+ clientType?: "script" | "module-federation";
20
+ reactFiles?: Record<string, string>;
21
+ reactActiveFile?: string;
22
+ }
23
+
24
+ const MFE_BROWSER_SECURITY_WORKSPACE = (() => {
25
+ const workspace = cloneFrontendLabWorkspace(
26
+ DEFAULT_MODULE_FEDERATION_LAB,
27
+ "module-federation",
28
+ );
29
+
30
+ return {
31
+ ...workspace,
32
+ label: "Webpack MF - CSP and XSS Boundaries",
33
+ activeFile: "apps/host/src/App.jsx",
34
+ files: {
35
+ ...workspace.files,
36
+ "README.md": `# Module Federation Security Lab
37
+
38
+ This workspace demonstrates two security ideas in a federated frontend:
39
+
40
+ 1. **CSP for Module Federation**
41
+ The host shell downloads remoteEntry.js files from other origins. That means the host CSP must explicitly allow each trusted remote origin in script-src.
42
+
43
+ 2. **XSS blast radius across remotes**
44
+ If any remote renders attacker-controlled HTML with dangerouslySetInnerHTML, that script runs in the same page as the host shell. A single vulnerable remote can compromise the whole composed UI.
45
+
46
+ ## What to inspect
47
+
48
+ - apps/host/webpack.config.js - CSP allowlist for remote origins
49
+ - apps/host/src/App.jsx - host passes one payload to both remotes
50
+ - apps/profile/src/ProfileCard.jsx - safe remote renders payload as text
51
+ - apps/checkout/src/CheckoutPanel.jsx - unsafe remote injects HTML directly
52
+
53
+ ## Suggested experiments
54
+
55
+ 1. Start webpack and switch the payload from safe text to attacker HTML.
56
+ 2. See the safe remote display the payload literally while the unsafe remote executes it.
57
+ 3. Remove one remote origin from script-src in apps/host/webpack.config.js and rerun webpack.
58
+ 4. Add a new remote and notice that CSP must be updated before the host can trust it.
59
+ `,
60
+ "apps/host/src/App.jsx": `import React, { Suspense, useMemo, useState } from "react";
61
+ import { makeInspectableLazy } from "../../shared/mfInspector";
62
+
63
+ const ProfileCard = React.lazy(
64
+ makeInspectableLazy(
65
+ "profile/ProfileCard",
66
+ () => import("profile/ProfileCard"),
67
+ () => import("profile/InspectorBridge"),
68
+ ),
69
+ );
70
+ const CheckoutPanel = React.lazy(
71
+ makeInspectableLazy(
72
+ "checkout/CheckoutPanel",
73
+ () => import("checkout/CheckoutPanel"),
74
+ () => import("checkout/InspectorBridge"),
75
+ ),
76
+ );
77
+
78
+ const SAFE_PROMO = "Spring release: 20% off for signed-in teams.";
79
+ const ATTACKER_PROMO =
80
+ '<img src="x" onerror="document.getElementById(\\'mf-xss-status\\').textContent=\\'XSS executed inside the unsafe checkout remote\\';document.getElementById(\\'mf-xss-status\\').style.color=\\'#dc2626\\';document.getElementById(\\'mf-xss-status\\').style.fontWeight=\\'700\\';" />';
81
+
82
+ function RemoteBoundary({ title, children }) {
83
+ return (
84
+ <div
85
+ style={{
86
+ border: "1px solid #cbd5e1",
87
+ borderRadius: "0.75rem",
88
+ padding: "1rem",
89
+ background: "#fff",
90
+ }}
91
+ >
92
+ <div
93
+ style={{
94
+ fontSize: "0.8rem",
95
+ color: "#64748b",
96
+ marginBottom: "0.75rem",
97
+ }}
98
+ >
99
+ {title}
100
+ </div>
101
+ <Suspense fallback={<p style={{ color: "#64748b" }}>Loading remote...</p>}>
102
+ {children}
103
+ </Suspense>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ export default function App() {
109
+ const [variant, setVariant] = useState("safe");
110
+
111
+ const promoHtml = useMemo(
112
+ () => (variant === "attacker" ? ATTACKER_PROMO : SAFE_PROMO),
113
+ [variant],
114
+ );
115
+
116
+ const resetStatus = () => {
117
+ const el = document.getElementById("mf-xss-status");
118
+ if (!el) return;
119
+ el.textContent = "No XSS executed yet.";
120
+ el.style.color = "#0f766e";
121
+ el.style.fontWeight = "600";
122
+ };
123
+
124
+ return (
125
+ <main
126
+ style={{
127
+ minHeight: "100vh",
128
+ margin: 0,
129
+ padding: "2rem",
130
+ background: "linear-gradient(135deg, #e0f2fe 0%, #f8fafc 50%, #fef3c7 100%)",
131
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
132
+ }}
133
+ >
134
+ <div style={{ maxWidth: "1180px", margin: "0 auto" }}>
135
+ <div style={{ marginBottom: "1.5rem" }}>
136
+ <p
137
+ style={{
138
+ margin: 0,
139
+ color: "#0369a1",
140
+ fontSize: "0.8rem",
141
+ letterSpacing: "0.08em",
142
+ textTransform: "uppercase",
143
+ }}
144
+ >
145
+ Webpack 5 Host
146
+ </p>
147
+ <h1 style={{ margin: "0.35rem 0 0", fontSize: "2rem", color: "#0f172a" }}>
148
+ CSP and XSS in a federated shell
149
+ </h1>
150
+ <p style={{ color: "#475569", maxWidth: "56rem" }}>
151
+ The host allowlists remote origins in CSP so Module Federation can load
152
+ trusted remotes. It then sends the same marketing payload to two remotes:
153
+ one renders it safely as text, the other renders it as raw HTML.
154
+ </p>
155
+ </div>
156
+
157
+ <section
158
+ style={{
159
+ display: "grid",
160
+ gridTemplateColumns: "1.15fr 1fr",
161
+ gap: "1rem",
162
+ marginBottom: "1rem",
163
+ }}
164
+ >
165
+ <div
166
+ style={{
167
+ background: "rgba(255,255,255,0.92)",
168
+ border: "1px solid #bfdbfe",
169
+ borderRadius: "1rem",
170
+ padding: "1rem",
171
+ }}
172
+ >
173
+ <h2 style={{ marginTop: 0, color: "#0f172a", fontSize: "1rem" }}>
174
+ Host controls
175
+ </h2>
176
+ <p style={{ marginTop: 0, color: "#475569", fontSize: "0.9rem" }}>
177
+ Toggle the same payload between safe text and attacker HTML.
178
+ If the unsafe remote injects it with <code>dangerouslySetInnerHTML</code>,
179
+ the payload executes in the host page.
180
+ </p>
181
+ <div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
182
+ <button
183
+ type="button"
184
+ onClick={() => {
185
+ resetStatus();
186
+ setVariant("safe");
187
+ }}
188
+ style={{
189
+ border: 0,
190
+ borderRadius: "999px",
191
+ background: variant === "safe" ? "#0f766e" : "#cbd5e1",
192
+ color: variant === "safe" ? "#ecfeff" : "#0f172a",
193
+ padding: "0.65rem 1rem",
194
+ fontWeight: 700,
195
+ cursor: "pointer",
196
+ }}
197
+ >
198
+ Use safe text
199
+ </button>
200
+ <button
201
+ type="button"
202
+ onClick={() => {
203
+ resetStatus();
204
+ setVariant("attacker");
205
+ }}
206
+ style={{
207
+ border: 0,
208
+ borderRadius: "999px",
209
+ background: variant === "attacker" ? "#b91c1c" : "#fecaca",
210
+ color: variant === "attacker" ? "#fff7ed" : "#7f1d1d",
211
+ padding: "0.65rem 1rem",
212
+ fontWeight: 700,
213
+ cursor: "pointer",
214
+ }}
215
+ >
216
+ Use attacker HTML
217
+ </button>
218
+ </div>
219
+
220
+ <p id="mf-xss-status" style={{ margin: "0 0 0.75rem", color: "#0f766e", fontWeight: 600 }}>
221
+ No XSS executed yet.
222
+ </p>
223
+
224
+ <pre
225
+ style={{
226
+ margin: 0,
227
+ padding: "0.9rem",
228
+ borderRadius: "0.75rem",
229
+ background: "#0f172a",
230
+ color: "#e2e8f0",
231
+ fontSize: "0.78rem",
232
+ whiteSpace: "pre-wrap",
233
+ wordBreak: "break-word",
234
+ }}
235
+ >
236
+ {promoHtml}
237
+ </pre>
238
+ </div>
239
+
240
+ <div
241
+ style={{
242
+ background: "rgba(255,255,255,0.92)",
243
+ border: "1px solid #fde68a",
244
+ borderRadius: "1rem",
245
+ padding: "1rem",
246
+ }}
247
+ >
248
+ <h2 style={{ marginTop: 0, color: "#0f172a", fontSize: "1rem" }}>
249
+ CSP angle
250
+ </h2>
251
+ <p style={{ marginTop: 0, color: "#475569", fontSize: "0.9rem" }}>
252
+ The host page sends a CSP header from <code>apps/host/webpack.config.js</code>.
253
+ It allows only the host plus the trusted remote origins in <code>script-src</code>.
254
+ </p>
255
+ <ol style={{ margin: 0, paddingLeft: "1.2rem", color: "#334155", fontSize: "0.9rem" }}>
256
+ <li>Remote entries still count as remote scripts.</li>
257
+ <li>Adding a new remote means updating the CSP allowlist.</li>
258
+ <li>XSS inside any allowed remote still runs in the shared host page.</li>
259
+ </ol>
260
+ </div>
261
+ </section>
262
+
263
+ <section
264
+ style={{
265
+ display: "grid",
266
+ gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
267
+ gap: "1rem",
268
+ }}
269
+ >
270
+ <RemoteBoundary title="profile/ProfileCard - safe remote using normal React text rendering">
271
+ <ProfileCard promoHtml={promoHtml} />
272
+ </RemoteBoundary>
273
+ <RemoteBoundary title="checkout/CheckoutPanel - unsafe remote using dangerouslySetInnerHTML">
274
+ <CheckoutPanel promoHtml={promoHtml} />
275
+ </RemoteBoundary>
276
+ </section>
277
+ </div>
278
+ </main>
279
+ );
280
+ }
281
+ `,
282
+ "apps/host/webpack.config.js": `const path = require("path");
283
+ const webpack = require("webpack");
284
+ const HtmlWebpackPlugin = require("html-webpack-plugin");
285
+ const { ModuleFederationPlugin } = webpack.container;
286
+ const packageJson = require("./package.json");
287
+ const { createSharedConfig } = require("../shared/buildSharedConfig");
288
+
289
+ const hostPort = Number(process.env.HOST_PORT || 3100);
290
+ const profilePort = Number(process.env.PROFILE_PORT || 3101);
291
+ const checkoutPort = Number(process.env.CHECKOUT_PORT || 3102);
292
+ const sharedConfig = createSharedConfig(packageJson);
293
+ const remoteConfig = {
294
+ profile: "profile@http://localhost:" + profilePort + "/remoteEntry.js",
295
+ checkout: "checkout@http://localhost:" + checkoutPort + "/remoteEntry.js",
296
+ };
297
+ const inspectorConfig = {
298
+ app: "host",
299
+ remotes: remoteConfig,
300
+ shared: sharedConfig,
301
+ };
302
+
303
+ module.exports = {
304
+ mode: "development",
305
+ devtool: "source-map",
306
+ entry: path.resolve(__dirname, "./src/index.jsx"),
307
+ output: {
308
+ path: path.resolve(__dirname, "./dist"),
309
+ publicPath: "http://localhost:" + hostPort + "/",
310
+ clean: true,
311
+ },
312
+ resolve: {
313
+ extensions: [".js", ".jsx"],
314
+ },
315
+ module: {
316
+ rules: [
317
+ {
318
+ test: /\\.(js|jsx)$/,
319
+ exclude: /node_modules/,
320
+ use: {
321
+ loader: "esbuild-loader",
322
+ options: {
323
+ loader: "jsx",
324
+ jsx: "automatic",
325
+ target: "es2020",
326
+ },
327
+ },
328
+ },
329
+ ],
330
+ },
331
+ devServer: {
332
+ port: hostPort,
333
+ historyApiFallback: true,
334
+ hot: true,
335
+ headers: {
336
+ "Access-Control-Allow-Origin": "*",
337
+ // Module Federation still downloads remoteEntry.js from other origins.
338
+ // CSP must allow every trusted remote origin in script-src.
339
+ "Content-Security-Policy": [
340
+ "default-src 'self'",
341
+ "script-src 'self' http://localhost:" + profilePort + " http://localhost:" + checkoutPort,
342
+ "style-src 'self' 'unsafe-inline'",
343
+ "img-src 'self' data:",
344
+ "font-src 'self' data:",
345
+ "connect-src 'self' http://localhost:" + hostPort + " ws://localhost:" + hostPort + " http://localhost:" + profilePort + " ws://localhost:" + profilePort + " http://localhost:" + checkoutPort + " ws://localhost:" + checkoutPort,
346
+ "object-src 'none'",
347
+ "base-uri 'self'",
348
+ "frame-ancestors 'self' http://localhost:5173 http://127.0.0.1:5173",
349
+ ].join("; "),
350
+ },
351
+ },
352
+ plugins: [
353
+ new webpack.DefinePlugin({
354
+ __MF_INSPECTOR_APP__: JSON.stringify("host"),
355
+ __MF_INSPECTOR_SANDBOX_ID__: JSON.stringify(process.env.MF_SANDBOX_ID || ""),
356
+ __MF_INSPECTOR_DECLARED_CONFIG__: JSON.stringify(inspectorConfig),
357
+ }),
358
+ new ModuleFederationPlugin({
359
+ name: "host",
360
+ remotes: remoteConfig,
361
+ shared: sharedConfig,
362
+ }),
363
+ new HtmlWebpackPlugin({
364
+ template: path.resolve(__dirname, "./public/index.html"),
365
+ }),
366
+ ],
367
+ };
368
+ `,
369
+ "apps/profile/src/App.jsx": `import React from "react";
370
+ import ProfileCard from "./ProfileCard";
371
+
372
+ export default function App() {
373
+ return (
374
+ <main style={{ padding: "2rem", fontFamily: "ui-sans-serif, system-ui, sans-serif", background: "#f8fafc", minHeight: "100vh" }}>
375
+ <p style={{ margin: 0, color: "#7c3aed", fontSize: "0.8rem", letterSpacing: "0.08em", textTransform: "uppercase" }}>
376
+ Remote App
377
+ </p>
378
+ <h1 style={{ margin: "0.35rem 0 1rem", color: "#1e293b" }}>Profile</h1>
379
+ <ProfileCard promoHtml="Spring release: 20% off for signed-in teams." />
380
+ </main>
381
+ );
382
+ }
383
+ `,
384
+ "apps/profile/src/ProfileCard.jsx": `import React from "react";
385
+
386
+ export default function ProfileCard({ promoHtml = "" }) {
387
+ return (
388
+ <section>
389
+ <h2 style={{ marginTop: 0, color: "#1e293b" }}>Safe federated profile card</h2>
390
+ <p style={{ color: "#475569" }}>
391
+ This remote treats host-provided markup as plain text. React escapes it,
392
+ so attacker-controlled HTML never becomes executable DOM here.
393
+ </p>
394
+ <div
395
+ style={{
396
+ borderRadius: "0.75rem",
397
+ border: "1px solid #cbd5e1",
398
+ padding: "0.85rem",
399
+ background: "#f8fafc",
400
+ color: "#0f172a",
401
+ whiteSpace: "pre-wrap",
402
+ wordBreak: "break-word",
403
+ }}
404
+ >
405
+ {promoHtml}
406
+ </div>
407
+ </section>
408
+ );
409
+ }
410
+ `,
411
+ "apps/profile/webpack.config.js": `const path = require("path");
412
+ const webpack = require("webpack");
413
+ const HtmlWebpackPlugin = require("html-webpack-plugin");
414
+ const { ModuleFederationPlugin } = webpack.container;
415
+ const packageJson = require("./package.json");
416
+ const { createSharedConfig } = require("../shared/buildSharedConfig");
417
+
418
+ const profilePort = Number(process.env.PROFILE_PORT || 3101);
419
+ const sharedConfig = createSharedConfig(packageJson);
420
+ const exposeConfig = {
421
+ "./ProfileCard": path.resolve(__dirname, "./src/ProfileCard.jsx"),
422
+ "./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.js"),
423
+ };
424
+ const inspectorConfig = {
425
+ app: "profile",
426
+ exposes: Object.keys(exposeConfig),
427
+ shared: sharedConfig,
428
+ };
429
+
430
+ module.exports = {
431
+ mode: "development",
432
+ devtool: "source-map",
433
+ entry: path.resolve(__dirname, "./src/index.jsx"),
434
+ output: {
435
+ path: path.resolve(__dirname, "./dist"),
436
+ publicPath: "http://localhost:" + profilePort + "/",
437
+ clean: true,
438
+ },
439
+ resolve: {
440
+ extensions: [".js", ".jsx"],
441
+ },
442
+ module: {
443
+ rules: [
444
+ {
445
+ test: /\\.(js|jsx)$/,
446
+ exclude: /node_modules/,
447
+ use: {
448
+ loader: "esbuild-loader",
449
+ options: {
450
+ loader: "jsx",
451
+ jsx: "automatic",
452
+ target: "es2020",
453
+ },
454
+ },
455
+ },
456
+ ],
457
+ },
458
+ devServer: {
459
+ port: profilePort,
460
+ historyApiFallback: true,
461
+ hot: true,
462
+ headers: {
463
+ "Access-Control-Allow-Origin": "*",
464
+ },
465
+ },
466
+ plugins: [
467
+ new webpack.DefinePlugin({
468
+ __MF_INSPECTOR_APP__: JSON.stringify("profile"),
469
+ __MF_INSPECTOR_SANDBOX_ID__: JSON.stringify(process.env.MF_SANDBOX_ID || ""),
470
+ __MF_INSPECTOR_DECLARED_CONFIG__: JSON.stringify(inspectorConfig),
471
+ }),
472
+ new ModuleFederationPlugin({
473
+ name: "profile",
474
+ filename: "remoteEntry.js",
475
+ exposes: exposeConfig,
476
+ shared: sharedConfig,
477
+ }),
478
+ new HtmlWebpackPlugin({
479
+ template: path.resolve(__dirname, "./public/index.html"),
480
+ }),
481
+ ],
482
+ };
483
+ `,
484
+ "apps/checkout/src/App.jsx": `import React from "react";
485
+ import CheckoutPanel from "./CheckoutPanel";
486
+
487
+ export default function App() {
488
+ return (
489
+ <main style={{ padding: "2rem", fontFamily: "ui-sans-serif, system-ui, sans-serif", background: "#fff7ed", minHeight: "100vh" }}>
490
+ <p style={{ margin: 0, color: "#ea580c", fontSize: "0.8rem", letterSpacing: "0.08em", textTransform: "uppercase" }}>
491
+ Remote App
492
+ </p>
493
+ <h1 style={{ margin: "0.35rem 0 1rem", color: "#7c2d12" }}>Checkout</h1>
494
+ <CheckoutPanel promoHtml='Spring release: 20% off for signed-in teams.' />
495
+ </main>
496
+ );
497
+ }
498
+ `,
499
+ "apps/checkout/src/CheckoutPanel.jsx": `import React from "react";
500
+
501
+ export default function CheckoutPanel({ promoHtml = "" }) {
502
+ return (
503
+ <section>
504
+ <h2 style={{ marginTop: 0, color: "#7c2d12" }}>Unsafe federated checkout panel</h2>
505
+ <p style={{ color: "#9a3412" }}>
506
+ This remote uses <code>dangerouslySetInnerHTML</code>. If the host or any
507
+ upstream service passes attacker-controlled HTML, the remote executes it
508
+ in the same page as the host shell.
509
+ </p>
510
+ <div
511
+ style={{
512
+ borderRadius: "0.75rem",
513
+ border: "1px solid #fdba74",
514
+ padding: "0.85rem",
515
+ background: "#fff7ed",
516
+ color: "#7c2d12",
517
+ minHeight: "3.5rem",
518
+ }}
519
+ dangerouslySetInnerHTML={{ __html: promoHtml }}
520
+ />
521
+ </section>
522
+ );
523
+ }
524
+ `,
525
+ "apps/checkout/webpack.config.js": `const path = require("path");
526
+ const webpack = require("webpack");
527
+ const HtmlWebpackPlugin = require("html-webpack-plugin");
528
+ const { ModuleFederationPlugin } = webpack.container;
529
+ const packageJson = require("./package.json");
530
+ const { createSharedConfig } = require("../shared/buildSharedConfig");
531
+
532
+ const checkoutPort = Number(process.env.CHECKOUT_PORT || 3102);
533
+ const sharedConfig = createSharedConfig(packageJson);
534
+ const exposeConfig = {
535
+ "./CheckoutPanel": path.resolve(__dirname, "./src/CheckoutPanel.jsx"),
536
+ "./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.js"),
537
+ };
538
+ const inspectorConfig = {
539
+ app: "checkout",
540
+ exposes: Object.keys(exposeConfig),
541
+ shared: sharedConfig,
542
+ };
543
+
544
+ module.exports = {
545
+ mode: "development",
546
+ devtool: "source-map",
547
+ entry: path.resolve(__dirname, "./src/index.jsx"),
548
+ output: {
549
+ path: path.resolve(__dirname, "./dist"),
550
+ publicPath: "http://localhost:" + checkoutPort + "/",
551
+ clean: true,
552
+ },
553
+ resolve: {
554
+ extensions: [".js", ".jsx"],
555
+ },
556
+ module: {
557
+ rules: [
558
+ {
559
+ test: /\\.(js|jsx)$/,
560
+ exclude: /node_modules/,
561
+ use: {
562
+ loader: "esbuild-loader",
563
+ options: {
564
+ loader: "jsx",
565
+ jsx: "automatic",
566
+ target: "es2020",
567
+ },
568
+ },
569
+ },
570
+ ],
571
+ },
572
+ devServer: {
573
+ port: checkoutPort,
574
+ historyApiFallback: true,
575
+ hot: true,
576
+ headers: {
577
+ "Access-Control-Allow-Origin": "*",
578
+ },
579
+ },
580
+ plugins: [
581
+ new webpack.DefinePlugin({
582
+ __MF_INSPECTOR_APP__: JSON.stringify("checkout"),
583
+ __MF_INSPECTOR_SANDBOX_ID__: JSON.stringify(process.env.MF_SANDBOX_ID || ""),
584
+ __MF_INSPECTOR_DECLARED_CONFIG__: JSON.stringify(inspectorConfig),
585
+ }),
586
+ new ModuleFederationPlugin({
587
+ name: "checkout",
588
+ filename: "remoteEntry.js",
589
+ exposes: exposeConfig,
590
+ shared: sharedConfig,
591
+ }),
592
+ new HtmlWebpackPlugin({
593
+ template: path.resolve(__dirname, "./public/index.html"),
594
+ }),
595
+ ],
596
+ };
597
+ `,
598
+ },
599
+ };
600
+ })();
601
+
602
+ export const BROWSER_SECURITY_TEMPLATES: BrowserSecurityTemplate[] = [
603
+ {
604
+ id: "csp",
605
+ label: "CSP Inline Script Boundary",
606
+ description:
607
+ "Compare the same server-rendered page with and without a Content-Security-Policy header.",
608
+ serverCode: `import express from 'express';
609
+
610
+ const app = express();
611
+
612
+ app.use((req, res, next) => {
613
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
614
+ if (origin) {
615
+ res.setHeader('Access-Control-Allow-Origin', origin);
616
+ res.setHeader('Vary', 'Origin');
617
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
618
+ } else {
619
+ res.setHeader('Access-Control-Allow-Origin', '*');
620
+ }
621
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
622
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token');
623
+ if (req.method === 'OPTIONS') {
624
+ res.sendStatus(204);
625
+ return;
626
+ }
627
+ next();
628
+ });
629
+
630
+ function renderPage(mode) {
631
+ const secure = mode === 'safe';
632
+ const title = secure ? 'CSP enabled' : 'No CSP header';
633
+ const intro = secure
634
+ ? 'The server still returns an inline <script>, but the browser should refuse to run it.'
635
+ : 'The same inline <script> is returned without CSP, so the browser will execute it.';
636
+ const policy = secure
637
+ ? "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
638
+ : 'No Content-Security-Policy header returned';
639
+
640
+ return [
641
+ '<!doctype html>',
642
+ '<html>',
643
+ '<head>',
644
+ ' <meta charset="utf-8" />',
645
+ ' <title>CSP demo</title>',
646
+ ' <style>',
647
+ ' body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 18px; margin: 0; }',
648
+ ' .panel { background: #111827; border: 1px solid #334155; border-radius: 12px; padding: 16px; }',
649
+ ' code { display: block; margin-top: 12px; color: #7dd3fc; white-space: pre-wrap; }',
650
+ ' </style>',
651
+ '</head>',
652
+ '<body>',
653
+ ' <div class="panel">',
654
+ ' <h1>' + title + '</h1>',
655
+ ' <p>' + intro + '</p>',
656
+ ' <p id="status" data-state="blocked">Inline script has not run.</p>',
657
+ ' <code>' + policy + '</code>',
658
+ ' </div>',
659
+ ' <script>',
660
+ " const status = document.getElementById('status');",
661
+ " status.textContent = 'Inline script executed. The page is vulnerable to injected scripts.';",
662
+ " status.dataset.state = 'executed';",
663
+ " status.style.color = '#fca5a5';",
664
+ ' </script>',
665
+ '</body>',
666
+ '</html>',
667
+ ].join('\\n');
668
+ }
669
+
670
+ app.get('/lab/csp/:mode', (req, res) => {
671
+ const mode = req.params.mode === 'safe' ? 'safe' : 'unsafe';
672
+ if (mode === 'safe') {
673
+ res.setHeader(
674
+ 'Content-Security-Policy',
675
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'",
676
+ );
677
+ }
678
+ res.send(renderPage(mode));
679
+ });
680
+
681
+ app.get('/api/csp/meta/:mode', (req, res) => {
682
+ const mode = req.params.mode === 'safe' ? 'safe' : 'unsafe';
683
+ res.json({
684
+ mode,
685
+ header:
686
+ mode === 'safe'
687
+ ? "Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
688
+ : 'No CSP header',
689
+ expected:
690
+ mode === 'safe'
691
+ ? 'The inline script is present in the HTML but should be blocked by the browser.'
692
+ : 'The inline script should run immediately because there is no CSP guardrail.',
693
+ });
694
+ });
695
+
696
+ const port = Number(process.env.PORT);
697
+ app.listen(port, () => console.log('Server on :' + port));
698
+ `,
699
+ clientCode: `document.body.innerHTML = '';
700
+
701
+ const root = document.createElement('div');
702
+ root.style.cssText = [
703
+ 'font-family: system-ui',
704
+ 'padding: 24px',
705
+ 'background: #020617',
706
+ 'color: #e2e8f0',
707
+ 'min-height: 100vh',
708
+ ].join(';');
709
+
710
+ root.innerHTML = [
711
+ '<h1 style="margin: 0 0 8px;">Content Security Policy</h1>',
712
+ '<p style="margin: 0 0 18px; color: #94a3b8; max-width: 900px;">',
713
+ 'The client asks the server for two routes and embeds both responses. Each response contains the same inline script. Only the CSP-protected response should keep that script from executing.',
714
+ '</p>',
715
+ '<div id="grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px;"></div>',
716
+ ].join('');
717
+
718
+ document.body.appendChild(root);
719
+
720
+ const grid = document.getElementById('grid');
721
+
722
+ async function addScenario(mode, title) {
723
+ const meta = await fetch(SANDBOX_URL + '/api/csp/meta/' + mode).then((res) => res.json());
724
+
725
+ const card = document.createElement('section');
726
+ card.style.cssText = 'background:#0f172a;border:1px solid #334155;border-radius:16px;padding:16px;display:flex;flex-direction:column;gap:12px;';
727
+
728
+ const heading = document.createElement('h2');
729
+ heading.textContent = title;
730
+ heading.style.margin = '0';
731
+
732
+ const details = document.createElement('pre');
733
+ details.textContent = meta.header + '\\n\\n' + meta.expected;
734
+ details.style.cssText = 'margin:0;padding:12px;border-radius:12px;background:#111827;color:#cbd5e1;font-size:12px;white-space:pre-wrap;';
735
+
736
+ const verdict = document.createElement('div');
737
+ verdict.textContent = mode === 'safe'
738
+ ? 'Inside the frame, the page should keep saying "Inline script has not run." because the CSP header blocks the inline script.'
739
+ : 'Inside the frame, the page should change to "Inline script executed..." because nothing blocked it.';
740
+ verdict.style.cssText = 'font-size:12px;color:#cbd5e1;';
741
+
742
+ const frame = document.createElement('iframe');
743
+ frame.src = SANDBOX_URL + '/lab/csp/' + mode;
744
+ frame.style.cssText = 'width:100%;height:240px;border:1px solid #334155;border-radius:12px;background:#fff;';
745
+
746
+ card.append(heading, details, verdict, frame);
747
+ grid.appendChild(card);
748
+ }
749
+
750
+ await addScenario('unsafe', 'Without CSP');
751
+ await addScenario('safe', 'With CSP');
752
+ `,
753
+ },
754
+ {
755
+ id: "xss",
756
+ label: "Reflected XSS Rendering",
757
+ description:
758
+ "Send one attacker-controlled string through unsafe and escaped server rendering paths.",
759
+ serverCode: `import express from 'express';
760
+
761
+ const app = express();
762
+
763
+ app.use((req, res, next) => {
764
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
765
+ if (origin) {
766
+ res.setHeader('Access-Control-Allow-Origin', origin);
767
+ res.setHeader('Vary', 'Origin');
768
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
769
+ } else {
770
+ res.setHeader('Access-Control-Allow-Origin', '*');
771
+ }
772
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
773
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token');
774
+ if (req.method === 'OPTIONS') {
775
+ res.sendStatus(204);
776
+ return;
777
+ }
778
+ next();
779
+ });
780
+
781
+ const DEFAULT_PAYLOAD = '<img src=x onerror="document.body.dataset.attack=\\'true\\';document.getElementById(\\'result\\').textContent=\\'Payload executed on the page\\';">';
782
+
783
+ function escapeHtml(value) {
784
+ return String(value)
785
+ .replace(/&/g, '&amp;')
786
+ .replace(/</g, '&lt;')
787
+ .replace(/>/g, '&gt;')
788
+ .replace(/"/g, '&quot;')
789
+ .replace(/'/g, '&#39;');
790
+ }
791
+
792
+ function renderProfile(name, safe) {
793
+ const renderedName = safe ? escapeHtml(name) : name;
794
+ const title = safe
795
+ ? 'Server escapes user input before rendering'
796
+ : 'Server injects raw user input directly into HTML';
797
+
798
+ return [
799
+ '<!doctype html>',
800
+ '<html>',
801
+ '<head>',
802
+ ' <meta charset="utf-8" />',
803
+ ' <title>XSS demo</title>',
804
+ ' <style>',
805
+ ' body { font-family: system-ui; background: #0f172a; color: #e2e8f0; padding: 18px; margin: 0; }',
806
+ ' .card { background: #111827; border: 1px solid #334155; border-radius: 12px; padding: 16px; }',
807
+ ' .input { margin-top: 12px; padding: 12px; border-radius: 10px; background: #020617; color: #cbd5e1; word-break: break-word; }',
808
+ ' </style>',
809
+ '</head>',
810
+ '<body>',
811
+ ' <div class="card">',
812
+ ' <h1>' + title + '</h1>',
813
+ ' <p id="result">No payload has executed.</p>',
814
+ ' <div class="input">Hello, ' + renderedName + '</div>',
815
+ ' </div>',
816
+ '</body>',
817
+ '</html>',
818
+ ].join('\\n');
819
+ }
820
+
821
+ app.get('/lab/xss/:mode', (req, res) => {
822
+ const mode = req.params.mode === 'safe' ? 'safe' : 'unsafe';
823
+ const name = typeof req.query.name === 'string' ? req.query.name : DEFAULT_PAYLOAD;
824
+ res.send(renderProfile(name, mode === 'safe'));
825
+ });
826
+
827
+ app.get('/api/xss/payload', (_req, res) => {
828
+ res.json({
829
+ payload: DEFAULT_PAYLOAD,
830
+ explanation:
831
+ 'The client fetches one attacker-controlled string, then sends it through both the unsafe and escaped server rendering paths.',
832
+ });
833
+ });
834
+
835
+ const port = Number(process.env.PORT);
836
+ app.listen(port, () => console.log('Server on :' + port));
837
+ `,
838
+ clientCode: `document.body.innerHTML = '';
839
+
840
+ const payloadInfo = await fetch(SANDBOX_URL + '/api/xss/payload').then((res) => res.json());
841
+
842
+ const root = document.createElement('div');
843
+ root.style.cssText = [
844
+ 'font-family: system-ui',
845
+ 'padding: 24px',
846
+ 'background: #020617',
847
+ 'color: #e2e8f0',
848
+ 'min-height: 100vh',
849
+ ].join(';');
850
+
851
+ root.innerHTML = [
852
+ '<h1 style="margin:0 0 8px;">Reflected XSS</h1>',
853
+ '<p style="margin:0 0 12px; color:#94a3b8; max-width: 900px;">' + payloadInfo.explanation + '</p>',
854
+ '<pre style="margin:0 0 18px;padding:12px;border-radius:12px;background:#111827;color:#fda4af;white-space:pre-wrap;">' + payloadInfo.payload.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>',
855
+ '<div id="grid" style="display:grid;grid-template-columns:repeat(auto-fit, minmax(320px, 1fr));gap:16px;"></div>',
856
+ ].join('');
857
+
858
+ document.body.appendChild(root);
859
+
860
+ const grid = document.getElementById('grid');
861
+ const encodedPayload = encodeURIComponent(payloadInfo.payload);
862
+
863
+ function mountFrame(mode, title) {
864
+ const card = document.createElement('section');
865
+ card.style.cssText = 'background:#0f172a;border:1px solid #334155;border-radius:16px;padding:16px;display:flex;flex-direction:column;gap:12px;';
866
+
867
+ const heading = document.createElement('h2');
868
+ heading.textContent = title;
869
+ heading.style.margin = '0';
870
+
871
+ const verdict = document.createElement('div');
872
+ verdict.textContent = mode === 'safe'
873
+ ? 'Inside the frame, the payload should stay visible as text because the server escaped it before rendering.'
874
+ : 'Inside the frame, the payload should execute and change the page content because the server rendered it raw.';
875
+ verdict.style.cssText = 'font-size:12px;color:#cbd5e1;';
876
+
877
+ const frame = document.createElement('iframe');
878
+ frame.src = SANDBOX_URL + '/lab/xss/' + mode + '?name=' + encodedPayload;
879
+ frame.style.cssText = 'width:100%;height:240px;border:1px solid #334155;border-radius:12px;background:#fff;';
880
+
881
+ card.append(heading, verdict, frame);
882
+ grid.appendChild(card);
883
+ }
884
+
885
+ mountFrame('unsafe', 'Unsafe interpolation');
886
+ mountFrame('safe', 'Escaped output');
887
+ `,
888
+ },
889
+ {
890
+ id: "csrf",
891
+ label: "CSRF Token Flow",
892
+ description:
893
+ "Walk through cookie-authenticated transfers with and without a server-side CSRF token check.",
894
+ serverCode: `import crypto from 'node:crypto';
895
+ import express from 'express';
896
+
897
+ const app = express();
898
+ app.use(express.json());
899
+
900
+ app.use((req, res, next) => {
901
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
902
+ if (origin) {
903
+ res.setHeader('Access-Control-Allow-Origin', origin);
904
+ res.setHeader('Vary', 'Origin');
905
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
906
+ } else {
907
+ res.setHeader('Access-Control-Allow-Origin', '*');
908
+ }
909
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
910
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token');
911
+ if (req.method === 'OPTIONS') {
912
+ res.sendStatus(204);
913
+ return;
914
+ }
915
+ next();
916
+ });
917
+
918
+ const sessions = new Map();
919
+
920
+ function cookieName(mode) {
921
+ return mode === 'safe' ? 'safe_sid' : 'unsafe_sid';
922
+ }
923
+
924
+ function sessionKey(mode, sid) {
925
+ return mode + ':' + sid;
926
+ }
927
+
928
+ function parseCookies(header = '') {
929
+ return header.split(';').reduce((acc, part) => {
930
+ const trimmed = part.trim();
931
+ if (!trimmed) return acc;
932
+ const eq = trimmed.indexOf('=');
933
+ if (eq === -1) return acc;
934
+ const name = trimmed.slice(0, eq);
935
+ const value = trimmed.slice(eq + 1);
936
+ acc[name] = decodeURIComponent(value);
937
+ return acc;
938
+ }, {});
939
+ }
940
+
941
+ function createSession(mode, res) {
942
+ const sid = crypto.randomUUID();
943
+ const record = {
944
+ balance: 1000,
945
+ csrfToken: crypto.randomBytes(16).toString('hex'),
946
+ };
947
+ sessions.set(sessionKey(mode, sid), record);
948
+ res.setHeader(
949
+ 'Set-Cookie',
950
+ cookieName(mode) + '=' + sid + '; Path=/; HttpOnly; SameSite=Lax',
951
+ );
952
+ return { sid, record };
953
+ }
954
+
955
+ function readSession(mode, req) {
956
+ const cookies = parseCookies(req.headers.cookie || '');
957
+ const sid = cookies[cookieName(mode)];
958
+ if (!sid) return null;
959
+ const record = sessions.get(sessionKey(mode, sid));
960
+ if (!record) return null;
961
+ return { sid, record };
962
+ }
963
+
964
+ app.post('/api/csrf/reset', (_req, res) => {
965
+ sessions.clear();
966
+ res.json({ ok: true });
967
+ });
968
+
969
+ app.post('/api/csrf/:mode/login', (req, res) => {
970
+ const mode = req.params.mode === 'safe' ? 'safe' : 'unsafe';
971
+ const session = createSession(mode, res);
972
+ res.json({
973
+ ok: true,
974
+ mode,
975
+ balance: session.record.balance,
976
+ csrfToken: mode === 'safe' ? session.record.csrfToken : null,
977
+ });
978
+ });
979
+
980
+ app.post('/api/csrf/:mode/transfer', (req, res) => {
981
+ const mode = req.params.mode === 'safe' ? 'safe' : 'unsafe';
982
+ const session = readSession(mode, req);
983
+ if (!session) {
984
+ return res.status(401).json({ ok: false, error: 'No authenticated session. Login first.' });
985
+ }
986
+
987
+ if (mode === 'safe') {
988
+ const token = req.get('x-csrf-token');
989
+ if (!token || token !== session.record.csrfToken) {
990
+ return res.status(403).json({ ok: false, error: 'CSRF token missing or invalid.' });
991
+ }
992
+ }
993
+
994
+ const amount = Math.max(0, Number(req.body.amount || 0));
995
+ session.record.balance = Math.max(0, session.record.balance - amount);
996
+ sessions.set(sessionKey(mode, session.sid), session.record);
997
+
998
+ res.json({
999
+ ok: true,
1000
+ mode,
1001
+ transferred: amount,
1002
+ balance: session.record.balance,
1003
+ });
1004
+ });
1005
+
1006
+ app.get('/api/csrf/:mode/state', (req, res) => {
1007
+ const mode = req.params.mode === 'safe' ? 'safe' : 'unsafe';
1008
+ const session = readSession(mode, req);
1009
+ if (!session) {
1010
+ return res.status(404).json({ ok: false, error: 'No session for this mode yet.' });
1011
+ }
1012
+ res.json({ ok: true, mode, balance: session.record.balance });
1013
+ });
1014
+
1015
+ const port = Number(process.env.PORT);
1016
+ app.listen(port, () => console.log('Server on :' + port));
1017
+ `,
1018
+ clientCode: `document.body.innerHTML = '';
1019
+
1020
+ const root = document.createElement('div');
1021
+ root.style.cssText = [
1022
+ 'font-family: system-ui',
1023
+ 'padding: 24px',
1024
+ 'background: #020617',
1025
+ 'color: #e2e8f0',
1026
+ 'min-height: 100vh',
1027
+ ].join(';');
1028
+
1029
+ root.innerHTML = [
1030
+ '<h1 style="margin:0 0 8px;">CSRF token flow</h1>',
1031
+ '<p style="margin:0 0 16px;color:#94a3b8;max-width:900px;">',
1032
+ 'The client logs into both server flows, simulates an attacker-forged POST, then retries the protected flow with the token that only the legitimate app received during login.',
1033
+ '</p>',
1034
+ '<div id="summary" style="display:grid;grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));gap:12px;margin-bottom:16px;"></div>',
1035
+ '<div id="log" style="background:#0f172a;border:1px solid #334155;border-radius:16px;padding:16px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;"></div>',
1036
+ ].join('');
1037
+
1038
+ document.body.appendChild(root);
1039
+
1040
+ const summary = document.getElementById('summary');
1041
+ const log = document.getElementById('log');
1042
+
1043
+ function addLog(message, color) {
1044
+ const line = document.createElement('div');
1045
+ line.textContent = message;
1046
+ line.style.color = color || '#cbd5e1';
1047
+ line.style.marginBottom = '6px';
1048
+ log.appendChild(line);
1049
+ }
1050
+
1051
+ async function requestJson(path, init = {}) {
1052
+ const headers = new Headers(init.headers || {});
1053
+ if (init.body && !headers.has('Content-Type')) {
1054
+ headers.set('Content-Type', 'application/json');
1055
+ }
1056
+
1057
+ const response = await fetch(SANDBOX_URL + path, {
1058
+ credentials: 'include',
1059
+ ...init,
1060
+ headers,
1061
+ });
1062
+
1063
+ const body = await response.json().catch(() => ({}));
1064
+ return { status: response.status, body };
1065
+ }
1066
+
1067
+ function addSummaryCard(title, body, tone) {
1068
+ const card = document.createElement('section');
1069
+ const border = tone === 'good' ? '#166534' : tone === 'bad' ? '#7f1d1d' : '#334155';
1070
+ const accent = tone === 'good' ? '#86efac' : tone === 'bad' ? '#fca5a5' : '#cbd5e1';
1071
+ card.style.cssText = 'background:#0f172a;border:1px solid ' + border + ';border-radius:16px;padding:16px;';
1072
+ card.innerHTML = [
1073
+ '<h2 style="margin:0 0 8px;color:' + accent + ';font-size:15px;">' + title + '</h2>',
1074
+ '<div style="font-size:13px;color:#cbd5e1;white-space:pre-wrap;">' + body + '</div>',
1075
+ ].join('');
1076
+ summary.appendChild(card);
1077
+ }
1078
+
1079
+ await requestJson('/api/csrf/reset', { method: 'POST' });
1080
+ addLog('1. Reset in-memory sessions so both flows start clean.');
1081
+
1082
+ const unsafeLogin = await requestJson('/api/csrf/unsafe/login', { method: 'POST' });
1083
+ addLog('2. Victim logs into the unsafe flow. Balance: ' + unsafeLogin.body.balance, '#93c5fd');
1084
+
1085
+ const forgedUnsafe = await requestJson('/api/csrf/unsafe/transfer', {
1086
+ method: 'POST',
1087
+ body: JSON.stringify({ amount: 150 }),
1088
+ });
1089
+ addLog(
1090
+ '3. Attacker forges POST /api/csrf/unsafe/transfer without any token. Status ' +
1091
+ forgedUnsafe.status +
1092
+ ' -> ' +
1093
+ JSON.stringify(forgedUnsafe.body),
1094
+ forgedUnsafe.status < 400 ? '#fca5a5' : '#cbd5e1',
1095
+ );
1096
+
1097
+ const unsafeState = await requestJson('/api/csrf/unsafe/state');
1098
+ addSummaryCard(
1099
+ 'Unsafe server',
1100
+ 'Forged transfer succeeded.\\nRemaining balance: ' + unsafeState.body.balance,
1101
+ 'bad',
1102
+ );
1103
+
1104
+ const safeLogin = await requestJson('/api/csrf/safe/login', { method: 'POST' });
1105
+ addLog('4. Victim logs into the safe flow and receives a CSRF token.', '#93c5fd');
1106
+
1107
+ const forgedSafe = await requestJson('/api/csrf/safe/transfer', {
1108
+ method: 'POST',
1109
+ body: JSON.stringify({ amount: 150 }),
1110
+ });
1111
+ addLog(
1112
+ '5. Attacker repeats the forged POST without the token. Status ' +
1113
+ forgedSafe.status +
1114
+ ' -> ' +
1115
+ JSON.stringify(forgedSafe.body),
1116
+ forgedSafe.status >= 400 ? '#86efac' : '#fca5a5',
1117
+ );
1118
+
1119
+ const legitSafe = await requestJson('/api/csrf/safe/transfer', {
1120
+ method: 'POST',
1121
+ headers: { 'x-csrf-token': safeLogin.body.csrfToken },
1122
+ body: JSON.stringify({ amount: 150 }),
1123
+ });
1124
+ addLog(
1125
+ '6. Legitimate client retries with the server-issued token. Status ' +
1126
+ legitSafe.status +
1127
+ ' -> ' +
1128
+ JSON.stringify(legitSafe.body),
1129
+ legitSafe.status < 400 ? '#86efac' : '#fca5a5',
1130
+ );
1131
+
1132
+ const safeState = await requestJson('/api/csrf/safe/state');
1133
+ addSummaryCard(
1134
+ 'Token-protected server',
1135
+ 'Forged request blocked.\\nLegitimate request succeeded.\\nRemaining balance: ' + safeState.body.balance,
1136
+ 'good',
1137
+ );
1138
+ `,
1139
+ },
1140
+ {
1141
+ id: "mfe-csp-xss",
1142
+ label: "Module Federation CSP and XSS",
1143
+ description:
1144
+ "See how host CSP must allow trusted remoteEntry origins, and how one unsafe remote can XSS the whole shell.",
1145
+ serverCode: `import express from 'express';
1146
+
1147
+ const app = express();
1148
+
1149
+ const hostPort = 3100;
1150
+ const profilePort = 3101;
1151
+ const checkoutPort = 3102;
1152
+
1153
+ app.use((_req, res, next) => {
1154
+ res.setHeader(
1155
+ 'Content-Security-Policy',
1156
+ [
1157
+ "default-src 'self'",
1158
+ "script-src 'self' http://localhost:" + profilePort + " http://localhost:" + checkoutPort,
1159
+ "style-src 'self' 'unsafe-inline'",
1160
+ "img-src 'self' data:",
1161
+ "font-src 'self' data:",
1162
+ "connect-src 'self' http://localhost:" + hostPort + " ws://localhost:" + hostPort + " http://localhost:" + profilePort + " ws://localhost:" + profilePort + " http://localhost:" + checkoutPort + " ws://localhost:" + checkoutPort,
1163
+ "object-src 'none'",
1164
+ "base-uri 'self'",
1165
+ "frame-ancestors 'none'",
1166
+ ].join('; '),
1167
+ );
1168
+ next();
1169
+ });
1170
+
1171
+ app.get('/explanation', (_req, res) => {
1172
+ res.json({
1173
+ why: [
1174
+ 'Module Federation still loads remoteEntry.js as remote scripts.',
1175
+ 'That means the host CSP has to allow each trusted remote origin in script-src.',
1176
+ 'But once a remote is trusted and mounted into the page, an XSS bug inside that remote still executes with host-page privileges.',
1177
+ ],
1178
+ });
1179
+ });
1180
+
1181
+ const port = Number(process.env.PORT);
1182
+ app.listen(port, () => console.log('Server on :' + port));
1183
+ `,
1184
+ clientCode: "",
1185
+ clientType: "module-federation",
1186
+ reactFiles: MFE_BROWSER_SECURITY_WORKSPACE.files,
1187
+ reactActiveFile: MFE_BROWSER_SECURITY_WORKSPACE.activeFile,
1188
+ },
1189
+ {
1190
+ id: "mfe-build-serve",
1191
+ label: "Module Federation: Build + Static Serve",
1192
+ description:
1193
+ "Webpack builds each app to dist/, then a plain Node HTTP server serves the static files — the way it works in production.",
1194
+ serverCode: `import express from 'express';
1195
+
1196
+ const app = express();
1197
+
1198
+ const hostPort = 3100;
1199
+ const profilePort = 3101;
1200
+ const checkoutPort = 3102;
1201
+
1202
+ // In this lab the webpack apps handle their own static serving.
1203
+ // This server is here so you can add any backend API endpoints
1204
+ // that the federated apps might call (e.g. auth, data).
1205
+ app.get('/api/health', (_req, res) => {
1206
+ res.json({ ok: true });
1207
+ });
1208
+
1209
+ const port = Number(process.env.PORT);
1210
+ app.listen(port, () => console.log('API on :' + port));
1211
+ `,
1212
+ clientCode: "",
1213
+ clientType: "module-federation",
1214
+ reactFiles: (() => {
1215
+ const workspace = cloneFrontendLabWorkspace(
1216
+ DEFAULT_MODULE_FEDERATION_LAB,
1217
+ "module-federation",
1218
+ );
1219
+
1220
+ // Build-time serve scripts for each app.
1221
+ // Each script runs webpack in build mode then starts a bare http.createServer
1222
+ // to serve the dist/ folder — this is the production pattern, not a dev server.
1223
+ const HOST_SERVE = `
1224
+ const http = require('http');
1225
+ const fs = require('fs');
1226
+ const path = require('path');
1227
+ const webpack = require('webpack');
1228
+
1229
+ const port = Number(process.env.HOST_PORT || 3100);
1230
+ const profilePort = Number(process.env.PROFILE_PORT || 3101);
1231
+ const checkoutPort = Number(process.env.CHECKOUT_PORT || 3102);
1232
+
1233
+ const MIME = {
1234
+ '.html': 'text/html',
1235
+ '.js': 'application/javascript; charset=utf-8',
1236
+ '.css': 'text/css',
1237
+ '.json': 'application/json',
1238
+ '.png': 'image/png',
1239
+ '.svg': 'image/svg+xml',
1240
+ '.ico': 'image/x-icon',
1241
+ '.map': 'application/json',
1242
+ };
1243
+
1244
+ // CSP is set by this Node server, not by webpack-dev-server.
1245
+ // In production this lives in your NGINX/Express config.
1246
+ const csp = [
1247
+ "default-src 'self'",
1248
+ "script-src 'self' http://localhost:" + profilePort + " http://localhost:" + checkoutPort,
1249
+ "style-src 'self' 'unsafe-inline'",
1250
+ "img-src 'self' data:",
1251
+ "font-src 'self' data:",
1252
+ "connect-src 'self' http://localhost:" + port + " http://localhost:" + profilePort + " http://localhost:" + checkoutPort,
1253
+ "object-src 'none'",
1254
+ "base-uri 'self'",
1255
+ "frame-ancestors 'self' http://localhost:5173 http://127.0.0.1:5173",
1256
+ ].join('; ');
1257
+
1258
+ console.log('[host] Building with webpack...');
1259
+ const config = require('./webpack.config.js');
1260
+ webpack(config, (err, stats) => {
1261
+ if (err) { console.error('[host] webpack error:', err.message); process.exit(1); }
1262
+ if (stats.hasErrors()) {
1263
+ console.error('[host] webpack compilation errors:');
1264
+ console.error(stats.toString({ colors: false, all: false, errors: true }));
1265
+ process.exit(1);
1266
+ }
1267
+ console.log('[host] Build complete. Starting static server...');
1268
+
1269
+ const dist = path.resolve(__dirname, 'dist');
1270
+ http.createServer((req, res) => {
1271
+ const url = req.url === '/' ? '/index.html' : req.url.split('?')[0];
1272
+ const filePath = path.join(dist, url);
1273
+ const ext = path.extname(filePath) || '.html';
1274
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
1275
+ res.writeHead(200, {
1276
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
1277
+ 'Content-Security-Policy': csp,
1278
+ 'Access-Control-Allow-Origin': '*',
1279
+ 'Cache-Control': 'no-store',
1280
+ });
1281
+ fs.createReadStream(filePath).pipe(res);
1282
+ } else {
1283
+ // SPA fallback
1284
+ const index = path.join(dist, 'index.html');
1285
+ res.writeHead(200, {
1286
+ 'Content-Type': 'text/html',
1287
+ 'Content-Security-Policy': csp,
1288
+ 'Access-Control-Allow-Origin': '*',
1289
+ 'Cache-Control': 'no-store',
1290
+ });
1291
+ fs.createReadStream(index).pipe(res);
1292
+ }
1293
+ }).listen(port, () => {
1294
+ console.log('[host] Listening on http://localhost:' + port + '/');
1295
+ });
1296
+ });
1297
+ `;
1298
+
1299
+ const REMOTE_SERVE = (appName: string) => `
1300
+ const http = require('http');
1301
+ const fs = require('fs');
1302
+ const path = require('path');
1303
+ const webpack = require('webpack');
1304
+
1305
+ const portKey = '${appName === "profile" ? "PROFILE_PORT" : "CHECKOUT_PORT"}';
1306
+ const port = Number(process.env[portKey] || ${appName === "profile" ? "3101" : "3102"});
1307
+
1308
+ const MIME = {
1309
+ '.js': 'application/javascript; charset=utf-8',
1310
+ '.html': 'text/html',
1311
+ '.css': 'text/css',
1312
+ '.json': 'application/json',
1313
+ '.map': 'application/json',
1314
+ '.png': 'image/png',
1315
+ '.svg': 'image/svg+xml',
1316
+ };
1317
+
1318
+ console.log('[${appName}] Building with webpack...');
1319
+ const config = require('./webpack.config.js');
1320
+ webpack(config, (err, stats) => {
1321
+ if (err) { console.error('[${appName}] webpack error:', err.message); process.exit(1); }
1322
+ if (stats.hasErrors()) {
1323
+ console.error('[${appName}] webpack compilation errors:');
1324
+ console.error(stats.toString({ colors: false, all: false, errors: true }));
1325
+ process.exit(1);
1326
+ }
1327
+ console.log('[${appName}] Build complete. Starting static server...');
1328
+
1329
+ const dist = path.resolve(__dirname, 'dist');
1330
+ http.createServer((req, res) => {
1331
+ const url = req.url === '/' ? '/index.html' : req.url.split('?')[0];
1332
+ const filePath = path.join(dist, url);
1333
+ const ext = path.extname(filePath) || '.html';
1334
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
1335
+ res.writeHead(200, {
1336
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
1337
+ 'Access-Control-Allow-Origin': '*',
1338
+ 'Cache-Control': 'no-store',
1339
+ });
1340
+ fs.createReadStream(filePath).pipe(res);
1341
+ } else {
1342
+ res.writeHead(404);
1343
+ res.end('Not found');
1344
+ }
1345
+ }).listen(port, () => {
1346
+ console.log('[${appName}] Listening on http://localhost:' + port + '/');
1347
+ });
1348
+ });
1349
+ `;
1350
+
1351
+ const MF_BUILD_SERVE_PACKAGE_JSON = `{
1352
+ "name": "webpack-module-federation-lab",
1353
+ "private": true,
1354
+ "workspaces": [
1355
+ "apps/host",
1356
+ "apps/profile",
1357
+ "apps/checkout"
1358
+ ],
1359
+ "scripts": {
1360
+ "dev": "concurrently -k -n host,profile,checkout -c cyan,magenta,yellow 'npm run buildserve --workspace=@mf-lab/host' 'npm run buildserve --workspace=@mf-lab/profile' 'npm run buildserve --workspace=@mf-lab/checkout'"
1361
+ },
1362
+ "devDependencies": {
1363
+ "concurrently": "^9.2.1"
1364
+ }
1365
+ }
1366
+ `;
1367
+
1368
+ const HOST_PACKAGE_JSON = `{
1369
+ "name": "@mf-lab/host",
1370
+ "private": true,
1371
+ "scripts": {
1372
+ "buildserve": "node buildAndServe.js"
1373
+ },
1374
+ "dependencies": {
1375
+ "react": "^19.0.0",
1376
+ "react-dom": "^19.0.0",
1377
+ "react-router-dom": "^7.6.1"
1378
+ },
1379
+ "devDependencies": {
1380
+ "esbuild": "^0.28.0",
1381
+ "esbuild-loader": "^4.4.3",
1382
+ "html-webpack-plugin": "^5.6.7",
1383
+ "webpack": "^5.106.2",
1384
+ "webpack-cli": "^7.0.2"
1385
+ }
1386
+ }
1387
+ `;
1388
+
1389
+ const REMOTE_PACKAGE_JSON = (name: string) => `{
1390
+ "name": "@mf-lab/${name}",
1391
+ "private": true,
1392
+ "scripts": {
1393
+ "buildserve": "node buildAndServe.js"
1394
+ },
1395
+ "dependencies": {
1396
+ "react": "^19.0.0",
1397
+ "react-dom": "^19.0.0",
1398
+ "react-router-dom": "^7.6.1"
1399
+ },
1400
+ "devDependencies": {
1401
+ "esbuild": "^0.28.0",
1402
+ "esbuild-loader": "^4.4.3",
1403
+ "html-webpack-plugin": "^5.6.7",
1404
+ "webpack": "^5.106.2",
1405
+ "webpack-cli": "^7.0.2"
1406
+ }
1407
+ }
1408
+ `;
1409
+
1410
+ return {
1411
+ ...workspace.files,
1412
+ "README.md": `# Module Federation: Build + Static Serve
1413
+
1414
+ This variant builds each app with webpack then serves the dist/ output with a plain Node
1415
+ HTTP server. This mirrors how a real production deployment works.
1416
+
1417
+ ## The key difference from the dev-server lab
1418
+
1419
+ - webpack-dev-server sets CSP in its own devServer.headers config
1420
+ - Here each app's buildAndServe.js starts an http.createServer that sets the headers
1421
+ - This is the real production pattern: your web server (NGINX, Express, CDN edge) owns headers
1422
+ - webpack knows nothing about HTTP headers in production
1423
+
1424
+ ## What to inspect
1425
+
1426
+ - apps/host/buildAndServe.js - builds webpack then starts a Node server with CSP
1427
+ - apps/profile/buildAndServe.js - builds webpack then starts a bare static server
1428
+ - apps/checkout/buildAndServe.js - same, but checkout remote
1429
+ - apps/host/webpack.config.js - no devServer.headers here: headers are now the server's job
1430
+
1431
+ ## Suggested experiments
1432
+
1433
+ 1. Remove a remote origin from the csp array in apps/host/buildAndServe.js and rerun.
1434
+ 2. Notice the browser blocks the remoteEntry.js fetch even though webpack built fine.
1435
+ 3. Add a response header in apps/host/buildAndServe.js and check it in DevTools Network tab.
1436
+ `,
1437
+ "package.json": MF_BUILD_SERVE_PACKAGE_JSON,
1438
+ "apps/host/package.json": HOST_PACKAGE_JSON,
1439
+ "apps/host/buildAndServe.js": HOST_SERVE,
1440
+ "apps/host/webpack.config.js": workspace.files[
1441
+ "apps/host/webpack.config.js"
1442
+ ]!.replace(
1443
+ /devServer:[\s\S]*?(?=\n plugins)/,
1444
+ "// No devServer block — in production the web server sets headers, not webpack\n",
1445
+ ).replace('mode: "development"', 'mode: "production"'),
1446
+ "apps/profile/package.json": REMOTE_PACKAGE_JSON("profile"),
1447
+ "apps/profile/buildAndServe.js": REMOTE_SERVE("profile"),
1448
+ "apps/profile/webpack.config.js": workspace.files[
1449
+ "apps/profile/webpack.config.js"
1450
+ ]!.replace(
1451
+ /devServer:[\s\S]*?(?=\n plugins)/,
1452
+ "// No devServer block\n",
1453
+ ).replace('mode: "development"', 'mode: "production"'),
1454
+ "apps/checkout/package.json": REMOTE_PACKAGE_JSON("checkout"),
1455
+ "apps/checkout/buildAndServe.js": REMOTE_SERVE("checkout"),
1456
+ "apps/checkout/webpack.config.js": workspace.files[
1457
+ "apps/checkout/webpack.config.js"
1458
+ ]!.replace(
1459
+ /devServer:[\s\S]*?(?=\n plugins)/,
1460
+ "// No devServer block\n",
1461
+ ).replace('mode: "development"', 'mode: "production"'),
1462
+ };
1463
+ })(),
1464
+ reactActiveFile: "apps/host/buildAndServe.js",
1465
+ },
1466
+ {
1467
+ id: "mfe-ssr-nextjs",
1468
+ label: "MFE + SSR: Next.js Host with Remote Render Endpoints",
1469
+ description:
1470
+ "A Next.js host server-renders pages by fetching HTML from remote /ssr endpoints, then hydrates them client-side with the same remoteEntry.js.",
1471
+ serverCode: `import express from 'express';
1472
+
1473
+ // This is here for any shared API logic.
1474
+ // The actual SSR rendering happens inside each MFE app's own server (apps/profile, apps/checkout)
1475
+ // and the Next.js host fetches from those /ssr endpoints at request time.
1476
+ const app = express();
1477
+ app.get('/api/health', (_req, res) => res.json({ ok: true }));
1478
+
1479
+ const port = Number(process.env.PORT);
1480
+ app.listen(port, () => console.log('API on :' + port));
1481
+ `,
1482
+ clientCode: "",
1483
+ clientType: "module-federation",
1484
+ reactActiveFile: "apps/host/app/page.js",
1485
+ reactFiles: (() => {
1486
+ // ─── Remote shared build helper ──────────────────────────────────────────
1487
+ // Each remote (profile, checkout) runs a Node script that:
1488
+ // 1. Builds with webpack (produces remoteEntry.js in dist/)
1489
+ // 2. Starts an HTTP server that serves dist/ AND a /ssr endpoint
1490
+ // that returns renderToString() output for the exposed component
1491
+ // The Next.js host calls /ssr at request time on the server → true SSR.
1492
+
1493
+ const REMOTE_SSR_SERVE = (
1494
+ appName: string,
1495
+ componentName: string,
1496
+ portEnvKey: string,
1497
+ defaultPort: number,
1498
+ ) => `
1499
+ const http = require('http');
1500
+ const fs = require('fs');
1501
+ const path = require('path');
1502
+ const webpack = require('webpack');
1503
+ const React = require('react');
1504
+ const { renderToString } = require('react-dom/server');
1505
+
1506
+ const port = Number(process.env.${portEnvKey} || ${defaultPort});
1507
+
1508
+ const MIME = {
1509
+ '.js': 'application/javascript; charset=utf-8',
1510
+ '.html': 'text/html',
1511
+ '.css': 'text/css',
1512
+ '.json': 'application/json',
1513
+ '.map': 'application/json',
1514
+ };
1515
+
1516
+ // Build the webpack bundle first so remoteEntry.js exists on disk.
1517
+ console.log('[${appName}] Building with webpack...');
1518
+ const config = require('./webpack.config.js');
1519
+ webpack(config, (err, stats) => {
1520
+ if (err || stats.hasErrors()) {
1521
+ console.error('[${appName}] Build failed:', err?.message ?? stats.toString({ errors: true, all: false }));
1522
+ process.exit(1);
1523
+ }
1524
+ console.log('[${appName}] Build done. Starting server...');
1525
+
1526
+ // Load the SSR component directly from source (not from the bundle).
1527
+ // In production you would build a separate CJS/ESM server bundle.
1528
+ // Here we require the JSX via esbuild-register for simplicity.
1529
+ require('esbuild-register/dist/node').register({ jsx: 'automatic' });
1530
+ const Component = require('./src/${componentName}.jsx').default;
1531
+
1532
+ const dist = path.resolve(__dirname, 'dist');
1533
+
1534
+ http.createServer((req, res) => {
1535
+ const url = req.url.split('?')[0];
1536
+
1537
+ // SSR endpoint – called by the Next.js host at request time
1538
+ if (url === '/ssr') {
1539
+ try {
1540
+ const html = renderToString(React.createElement(Component));
1541
+ res.writeHead(200, {
1542
+ 'Content-Type': 'application/json',
1543
+ 'Access-Control-Allow-Origin': '*',
1544
+ });
1545
+ res.end(JSON.stringify({ html, componentName: '${componentName}' }));
1546
+ } catch (ssrErr) {
1547
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1548
+ res.end(JSON.stringify({ error: String(ssrErr) }));
1549
+ }
1550
+ return;
1551
+ }
1552
+
1553
+ // Static file serving – remoteEntry.js and other webpack output
1554
+ const filePath = path.join(dist, url === '/' ? '/index.html' : url);
1555
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
1556
+ const ext = path.extname(filePath) || '.js';
1557
+ res.writeHead(200, {
1558
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
1559
+ 'Access-Control-Allow-Origin': '*',
1560
+ 'Cache-Control': 'no-store',
1561
+ });
1562
+ fs.createReadStream(filePath).pipe(res);
1563
+ } else {
1564
+ res.writeHead(404);
1565
+ res.end('Not found');
1566
+ }
1567
+ }).listen(port, () => {
1568
+ console.log('[${appName}] Listening on http://localhost:' + port + '/');
1569
+ });
1570
+ });
1571
+ `;
1572
+
1573
+ const REMOTE_PACKAGE = (name: string) => `{
1574
+ "name": "@mf-ssr/${name}",
1575
+ "private": true,
1576
+ "scripts": {
1577
+ "build": "npx webpack --config webpack.config.js",
1578
+ "buildserve": "node buildAndServe.js"
1579
+ },
1580
+ "dependencies": {
1581
+ "react": "^18.3.1",
1582
+ "react-dom": "^18.3.1"
1583
+ },
1584
+ "devDependencies": {
1585
+ "esbuild": "^0.28.0",
1586
+ "esbuild-loader": "^4.4.3",
1587
+ "esbuild-register": "^3.6.0",
1588
+ "html-webpack-plugin": "^5.6.7",
1589
+ "webpack": "^5.106.2",
1590
+ "webpack-cli": "^7.0.2"
1591
+ }
1592
+ }
1593
+ `;
1594
+
1595
+ const REMOTE_WEBPACK = (
1596
+ name: string,
1597
+ portEnvKey: string,
1598
+ defaultPort: number,
1599
+ componentFile: string,
1600
+ ) => `
1601
+ const path = require('path');
1602
+ const webpack = require('webpack');
1603
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
1604
+ const { ModuleFederationPlugin } = webpack.container;
1605
+
1606
+ const port = Number(process.env.${portEnvKey} || ${defaultPort});
1607
+
1608
+ module.exports = {
1609
+ mode: 'production',
1610
+ entry: path.resolve(__dirname, './src/index.js'),
1611
+ output: {
1612
+ path: path.resolve(__dirname, './dist'),
1613
+ publicPath: 'http://localhost:' + port + '/',
1614
+ clean: true,
1615
+ },
1616
+ resolve: { extensions: ['.js', '.jsx'] },
1617
+ module: {
1618
+ rules: [{
1619
+ test: /\\.jsx?$/,
1620
+ exclude: /node_modules/,
1621
+ use: { loader: 'esbuild-loader', options: { loader: 'jsx', jsx: 'automatic', target: 'es2020' } },
1622
+ }],
1623
+ },
1624
+ plugins: [
1625
+ new ModuleFederationPlugin({
1626
+ name: '${name}',
1627
+ filename: 'remoteEntry.js',
1628
+ exposes: { './${componentFile}': path.resolve(__dirname, './src/${componentFile}.jsx') },
1629
+ shared: {
1630
+ react: { singleton: true, requiredVersion: '^18.3.1' },
1631
+ 'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
1632
+ },
1633
+ }),
1634
+ new HtmlWebpackPlugin({ template: path.resolve(__dirname, './public/index.html') }),
1635
+ ],
1636
+ };
1637
+ `;
1638
+
1639
+ const PROFILE_CARD_JSX = `import React from 'react';
1640
+
1641
+ // This component runs on the server (renderToString) AND in the browser (hydration).
1642
+ // SSR means the HTML is generated here on the remote's Node server at request time,
1643
+ // sent to the Next.js host, inserted into the page, then React takes over client-side.
1644
+ export default function ProfileCard({ ssrContext = false }) {
1645
+ return (
1646
+ <section style={{ fontFamily: 'ui-sans-serif, system-ui, sans-serif' }}>
1647
+ <div style={{
1648
+ background: 'linear-gradient(135deg, #ede9fe, #f8fafc)',
1649
+ borderRadius: '0.75rem',
1650
+ border: '1px solid #ddd6fe',
1651
+ padding: '1.25rem',
1652
+ }}>
1653
+ <p style={{ margin: '0 0 0.5rem', color: '#7c3aed', fontSize: '0.75rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
1654
+ {ssrContext ? 'Rendered on remote server → sent to Next.js host' : 'Hydrated in browser'}
1655
+ </p>
1656
+ <h2 style={{ margin: '0 0 0.75rem', color: '#1e293b' }}>Profile Remote</h2>
1657
+ <p style={{ margin: 0, color: '#475569', fontSize: '0.9rem' }}>
1658
+ This component was server-rendered by the profile remote app, not by Next.js itself.
1659
+ The host received raw HTML from <code>http://profile-remote/ssr</code> and inserted it
1660
+ before the page was sent to the browser.
1661
+ </p>
1662
+ <dl style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '0.4rem 1rem', marginTop: '1rem' }}>
1663
+ <dt style={{ color: '#94a3b8', fontSize: '0.8rem' }}>Owner</dt>
1664
+ <dd style={{ margin: 0, color: '#0f172a', fontSize: '0.8rem' }}>Platform Team</dd>
1665
+ <dt style={{ color: '#94a3b8', fontSize: '0.8rem' }}>Render origin</dt>
1666
+ <dd style={{ margin: 0, color: '#0f172a', fontSize: '0.8rem' }}>profile remote /ssr</dd>
1667
+ </dl>
1668
+ </div>
1669
+ </section>
1670
+ );
1671
+ }
1672
+ `;
1673
+
1674
+ const CHECKOUT_PANEL_JSX = `import React from 'react';
1675
+
1676
+ // Same SSR pattern as ProfileCard: rendered to HTML string on the remote Node server,
1677
+ // fetched by the Next.js host during getServerSideProps/page render, then hydrated.
1678
+ export default function CheckoutPanel({ ssrContext = false }) {
1679
+ return (
1680
+ <section style={{ fontFamily: 'ui-sans-serif, system-ui, sans-serif' }}>
1681
+ <div style={{
1682
+ background: 'linear-gradient(135deg, #fff7ed, #fef9f0)',
1683
+ borderRadius: '0.75rem',
1684
+ border: '1px solid #fed7aa',
1685
+ padding: '1.25rem',
1686
+ }}>
1687
+ <p style={{ margin: '0 0 0.5rem', color: '#ea580c', fontSize: '0.75rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
1688
+ {ssrContext ? 'Rendered on remote server → sent to Next.js host' : 'Hydrated in browser'}
1689
+ </p>
1690
+ <h2 style={{ margin: '0 0 0.75rem', color: '#7c2d12' }}>Checkout Remote</h2>
1691
+ <p style={{ margin: 0, color: '#9a3412', fontSize: '0.9rem' }}>
1692
+ This component was server-rendered by the checkout remote, not by Next.js.
1693
+ The host called <code>http://checkout-remote/ssr</code> and got back an HTML string
1694
+ that was embedded in the page before it reached the browser.
1695
+ </p>
1696
+ <div style={{ marginTop: '1rem', padding: '0.75rem', background: '#fff7ed', borderRadius: '0.5rem', border: '1px solid #fdba74' }}>
1697
+ <p style={{ margin: 0, color: '#7c2d12', fontSize: '0.85rem', fontWeight: 600 }}>
1698
+ Key SSR insight: the remote owns its own render logic.
1699
+ </p>
1700
+ <p style={{ margin: '0.25rem 0 0', color: '#9a3412', fontSize: '0.8rem' }}>
1701
+ If checkout needs user data it can fetch it server-side itself before rendering,
1702
+ rather than waiting for the browser to fetch and rerender.
1703
+ </p>
1704
+ </div>
1705
+ </div>
1706
+ </section>
1707
+ );
1708
+ }
1709
+ `;
1710
+
1711
+ const REMOTE_INDEX_JS = `// Dynamic import acts as the async boundary that Module Federation needs.
1712
+ // Without this the shared React singleton handshake would run synchronously
1713
+ // and could break if the module scope initialises before the share scope is ready.
1714
+ import('./bootstrap');
1715
+ `;
1716
+
1717
+ const REMOTE_BOOTSTRAP = (
1718
+ componentName: string,
1719
+ ) => `import React from 'react';
1720
+ import { createRoot } from 'react-dom/client';
1721
+ import ${componentName} from './${componentName}.jsx';
1722
+
1723
+ // Standalone preview when you open the remote's own URL directly.
1724
+ const root = document.getElementById('root');
1725
+ if (root) {
1726
+ createRoot(root).render(React.createElement(${componentName}));
1727
+ }
1728
+ `;
1729
+
1730
+ const REMOTE_INDEX_HTML = (title: string) => `<!doctype html>
1731
+ <html lang="en">
1732
+ <head>
1733
+ <meta charset="utf-8" />
1734
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1735
+ <title>${title}</title>
1736
+ </head>
1737
+ <body>
1738
+ <div id="root"></div>
1739
+ </body>
1740
+ </html>
1741
+ `;
1742
+
1743
+ // ─── Next.js host ─────────────────────────────────────────────────────────
1744
+ // Uses App Router. The page is a Server Component that fetches /ssr from
1745
+ // each remote at render time. No client-side fetching — this is real SSR.
1746
+
1747
+ const HOST_PAGE_JS = `// force-dynamic tells Next.js never to statically generate this page.
1748
+ // Without it, 'next build' would try to pre-render the page and fetch the
1749
+ // remote /ssr endpoints at build time — which fails because the remotes
1750
+ // aren't running during the build step.
1751
+ export const dynamic = 'force-dynamic';
1752
+
1753
+ // This is a React Server Component (Next.js App Router).
1754
+ // It runs ONLY on the server — no useState, no useEffect.
1755
+ // It fetches the pre-rendered HTML from each remote's /ssr endpoint
1756
+ // and injects it directly into the page as raw HTML.
1757
+ // The browser never has to wait for a client fetch — the HTML is already there.
1758
+
1759
+ async function fetchRemoteSSR(url) {
1760
+ try {
1761
+ const res = await fetch(url, { cache: 'no-store' });
1762
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1763
+ const data = await res.json();
1764
+ return data.html ?? '<p style="color:red">No HTML from remote</p>';
1765
+ } catch (err) {
1766
+ return '<p style="color:#dc2626;padding:1rem;border:1px solid #fca5a5;border-radius:0.5rem">' +
1767
+ 'Remote SSR failed: ' + String(err) + '</p>';
1768
+ }
1769
+ }
1770
+
1771
+ export default async function HomePage() {
1772
+ const profilePort = process.env.PROFILE_PORT || '3101';
1773
+ const checkoutPort = process.env.CHECKOUT_PORT || '3102';
1774
+
1775
+ // These fetches happen on the server during the render pass.
1776
+ // The browser receives complete HTML — no loading spinners for these sections.
1777
+ const [profileHtml, checkoutHtml] = await Promise.all([
1778
+ fetchRemoteSSR('http://localhost:' + profilePort + '/ssr'),
1779
+ fetchRemoteSSR('http://localhost:' + checkoutPort + '/ssr'),
1780
+ ]);
1781
+
1782
+ return (
1783
+ <main style={{ minHeight: '100vh', margin: 0, padding: '2rem',
1784
+ background: 'linear-gradient(135deg,#e0f2fe 0%,#f8fafc 50%,#fef3c7 100%)',
1785
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif' }}>
1786
+ <div style={{ maxWidth: '1100px', margin: '0 auto' }}>
1787
+
1788
+ <div style={{ marginBottom: '1.5rem' }}>
1789
+ <p style={{ margin: 0, color: '#0369a1', fontSize: '0.75rem',
1790
+ letterSpacing: '0.08em', textTransform: 'uppercase' }}>
1791
+ Next.js App Router — Server Component
1792
+ </p>
1793
+ <h1 style={{ margin: '0.35rem 0 0', fontSize: '2rem', color: '#0f172a' }}>
1794
+ SSR with Module Federation remotes
1795
+ </h1>
1796
+ <p style={{ color: '#475569', maxWidth: '52rem', marginTop: '0.5rem' }}>
1797
+ This page is a Server Component. It ran on the Next.js server, fetched HTML
1798
+ from the profile and checkout remotes, and sent the complete page to the browser.
1799
+ No client-side loading of these sections.
1800
+ </p>
1801
+ </div>
1802
+
1803
+ <div style={{ background: 'rgba(255,255,255,0.85)', border: '1px solid #bfdbfe',
1804
+ borderRadius: '1rem', padding: '1rem', marginBottom: '1.5rem', fontSize: '0.85rem', color: '#1e40af' }}>
1805
+ <strong>How this differs from pure client-side MFE:</strong>
1806
+ <ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem', lineHeight: 1.7 }}>
1807
+ <li>The host does a server-to-server HTTP call to each remote's <code>/ssr</code> endpoint</li>
1808
+ <li>Each remote runs <code>renderToString()</code> on its own Node server</li>
1809
+ <li>The host embeds the resulting HTML before sending the page to the browser</li>
1810
+ <li>With pure client MFE the browser would load remoteEntry.js first, then render</li>
1811
+ </ul>
1812
+ </div>
1813
+
1814
+ <section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(320px,1fr))', gap: '1rem' }}>
1815
+ <div>
1816
+ <p style={{ margin: '0 0 0.5rem', color: '#64748b', fontSize: '0.75rem' }}>
1817
+ profile remote — HTML from /ssr injected at server render time
1818
+ </p>
1819
+ <div dangerouslySetInnerHTML={{ __html: profileHtml }} />
1820
+ </div>
1821
+ <div>
1822
+ <p style={{ margin: '0 0 0.5rem', color: '#64748b', fontSize: '0.75rem' }}>
1823
+ checkout remote — HTML from /ssr injected at server render time
1824
+ </p>
1825
+ <div dangerouslySetInnerHTML={{ __html: checkoutHtml }} />
1826
+ </div>
1827
+ </section>
1828
+
1829
+ </div>
1830
+ </main>
1831
+ );
1832
+ }
1833
+ `;
1834
+
1835
+ const HOST_LAYOUT_JS = `export const metadata = { title: 'MFE SSR Host' };
1836
+
1837
+ export default function RootLayout({ children }) {
1838
+ return (
1839
+ <html lang="en">
1840
+ <body style={{ margin: 0 }}>{children}</body>
1841
+ </html>
1842
+ );
1843
+ }
1844
+ `;
1845
+
1846
+ const HOST_NEXT_CONFIG = `/** @type {import('next').NextConfig} */
1847
+ const nextConfig = {
1848
+ // Allow the Next.js server to make HTTP requests to local remote servers.
1849
+ // In production you'd list your real remote origins here.
1850
+ experimental: {},
1851
+ };
1852
+
1853
+ module.exports = nextConfig;
1854
+ `;
1855
+
1856
+ const HOST_PACKAGE = `{
1857
+ "name": "@mf-ssr/host",
1858
+ "private": true,
1859
+ "scripts": {
1860
+ "build": "npx next build",
1861
+ "buildserve": "node buildAndServe.js"
1862
+ },
1863
+ "dependencies": {
1864
+ "next": "^14.2.29",
1865
+ "react": "^18.3.1",
1866
+ "react-dom": "^18.3.1"
1867
+ }
1868
+ }
1869
+ `;
1870
+
1871
+ // Next.js build + start via programmatic API (avoids CLI path issues in workspaces)
1872
+ const HOST_BUILD_SERVE = `
1873
+ const http = require('http');
1874
+ const path = require('path');
1875
+ const { execSync } = require('child_process');
1876
+
1877
+ const port = Number(process.env.HOST_PORT || 3100);
1878
+ const appDir = __dirname;
1879
+
1880
+ console.log('[host] Building Next.js app...');
1881
+ // next build writes to .next/ — use npx to resolve from workspace root
1882
+ execSync('npx next build', {
1883
+ cwd: appDir,
1884
+ stdio: 'inherit',
1885
+ env: {
1886
+ ...process.env,
1887
+ NODE_ENV: 'production',
1888
+ PROFILE_PORT: process.env.PROFILE_PORT || '3101',
1889
+ CHECKOUT_PORT: process.env.CHECKOUT_PORT || '3102',
1890
+ },
1891
+ });
1892
+ console.log('[host] Build complete. Starting Next.js server...');
1893
+
1894
+ // next start runs on its own port; we just proxy-wrap it so we can log the URL
1895
+ // using the standard format the cockpit readiness detector watches for.
1896
+ execSync('npx next start --port ' + port, {
1897
+ cwd: appDir,
1898
+ stdio: 'inherit',
1899
+ env: {
1900
+ ...process.env,
1901
+ NODE_ENV: 'production',
1902
+ PORT: String(port),
1903
+ PROFILE_PORT: process.env.PROFILE_PORT || '3101',
1904
+ CHECKOUT_PORT: process.env.CHECKOUT_PORT || '3102',
1905
+ },
1906
+ });
1907
+ // next start blocks; print the URL before it exits so the readiness detector sees it.
1908
+ console.log('[host] Listening on http://localhost:' + port + '/');
1909
+ `;
1910
+
1911
+ const ROOT_PACKAGE = `{
1912
+ "name": "mfe-ssr-lab",
1913
+ "private": true,
1914
+ "workspaces": [
1915
+ "apps/host",
1916
+ "apps/profile",
1917
+ "apps/checkout"
1918
+ ],
1919
+ "scripts": {
1920
+ "dev": "concurrently -k -n profile,checkout,host -c magenta,yellow,cyan 'npm run buildserve --workspace=@mf-ssr/profile' 'npm run buildserve --workspace=@mf-ssr/checkout' 'npm run buildserve --workspace=@mf-ssr/host'",
1921
+ "build": "npm run build --workspace=@mf-ssr/profile && npm run build --workspace=@mf-ssr/checkout && npm run build --workspace=@mf-ssr/host",
1922
+ "build:profile": "npm run build --workspace=@mf-ssr/profile",
1923
+ "build:checkout": "npm run build --workspace=@mf-ssr/checkout",
1924
+ "build:host": "npm run build --workspace=@mf-ssr/host"
1925
+ },
1926
+ "devDependencies": {
1927
+ "concurrently": "^9.2.1"
1928
+ }
1929
+ }
1930
+ `;
1931
+
1932
+ const README = `# MFE + SSR: Next.js Host with Remote Render Endpoints
1933
+
1934
+ ## Architecture
1935
+
1936
+ \`\`\`
1937
+ Browser
1938
+ └─ requests page from Next.js host (port HOST_PORT)
1939
+
1940
+ Next.js Server Component runs:
1941
+ ├─ fetch("http://localhost:PROFILE_PORT/ssr") → profile remote Node server
1942
+ │ returns { html: "<section>...</section>" }
1943
+ └─ fetch("http://localhost:CHECKOUT_PORT/ssr") → checkout remote Node server
1944
+ returns { html: "<section>...</section>" }
1945
+
1946
+ Next.js assembles complete HTML and sends it to the browser
1947
+ Browser receives fully-rendered page (no JS needed for initial content)
1948
+ \`\`\`
1949
+
1950
+ ## How each piece works
1951
+
1952
+ ### Remotes (profile, checkout)
1953
+ - Webpack production build produces \`remoteEntry.js\` in \`dist/\`
1954
+ - A Node \`http.createServer\` serves two things:
1955
+ - \`GET /remoteEntry.js\` — the client-side module federation bundle
1956
+ - \`GET /ssr\` — calls \`renderToString(<Component />)\` and returns \`{ html }\`
1957
+
1958
+ ### Host (Next.js App Router)
1959
+ - \`apps/host/app/page.js\` is a Server Component
1960
+ - It \`await Promise.all\`s both \`/ssr\` fetches during the server render pass
1961
+ - The browser receives complete HTML — no client loading spinners for remote content
1962
+ - \`dangerouslySetInnerHTML\` injects the remote HTML (safe here because we control the remote servers)
1963
+
1964
+ ## Key learning points
1965
+
1966
+ 1. **SSR is not free** — the host now depends on the remotes being up at render time
1967
+ 2. **The /ssr endpoint is a contract** — remotes own their own server-side rendering logic
1968
+ 3. **No webpack MF plugin needed on the host** — it just does a fetch, like any other API call
1969
+ 4. **Hydration gap** — the HTML is there but React doesn\\'t hydrate these remote sections yet
1970
+ (to add hydration you\\'d load remoteEntry.js client-side and call hydrateRoot())
1971
+
1972
+ ## Experiments
1973
+
1974
+ 1. Stop a remote (kill it from the console) and reload the page — see the fallback HTML
1975
+ 2. Add props to the /ssr endpoint (e.g. \`?userId=123\`) and use them in renderToString
1976
+ 3. Add a delay to a remote\\'s /ssr handler — watch it affect the host\\'s TTFB
1977
+ 4. Add \`hydrateRoot()\` in a client component to make remote sections interactive
1978
+ `;
1979
+
1980
+ return {
1981
+ "README.md": README,
1982
+ "package.json": ROOT_PACKAGE,
1983
+
1984
+ // profile remote
1985
+ "apps/profile/package.json": REMOTE_PACKAGE("profile"),
1986
+ "apps/profile/buildAndServe.js": REMOTE_SSR_SERVE(
1987
+ "profile",
1988
+ "ProfileCard",
1989
+ "PROFILE_PORT",
1990
+ 3101,
1991
+ ),
1992
+ "apps/profile/webpack.config.js": REMOTE_WEBPACK(
1993
+ "profile",
1994
+ "PROFILE_PORT",
1995
+ 3101,
1996
+ "ProfileCard",
1997
+ ),
1998
+ "apps/profile/public/index.html": REMOTE_INDEX_HTML("Profile Remote"),
1999
+ "apps/profile/src/index.js": REMOTE_INDEX_JS,
2000
+ "apps/profile/src/bootstrap.js": REMOTE_BOOTSTRAP("ProfileCard"),
2001
+ "apps/profile/src/ProfileCard.jsx": PROFILE_CARD_JSX,
2002
+
2003
+ // checkout remote
2004
+ "apps/checkout/package.json": REMOTE_PACKAGE("checkout"),
2005
+ "apps/checkout/buildAndServe.js": REMOTE_SSR_SERVE(
2006
+ "checkout",
2007
+ "CheckoutPanel",
2008
+ "CHECKOUT_PORT",
2009
+ 3102,
2010
+ ),
2011
+ "apps/checkout/webpack.config.js": REMOTE_WEBPACK(
2012
+ "checkout",
2013
+ "CHECKOUT_PORT",
2014
+ 3102,
2015
+ "CheckoutPanel",
2016
+ ),
2017
+ "apps/checkout/public/index.html": REMOTE_INDEX_HTML("Checkout Remote"),
2018
+ "apps/checkout/src/index.js": REMOTE_INDEX_JS,
2019
+ "apps/checkout/src/bootstrap.js": REMOTE_BOOTSTRAP("CheckoutPanel"),
2020
+ "apps/checkout/src/CheckoutPanel.jsx": CHECKOUT_PANEL_JSX,
2021
+
2022
+ // Next.js host
2023
+ "apps/host/package.json": HOST_PACKAGE,
2024
+ "apps/host/next.config.js": HOST_NEXT_CONFIG,
2025
+ "apps/host/buildAndServe.js": HOST_BUILD_SERVE,
2026
+ "apps/host/app/layout.js": HOST_LAYOUT_JS,
2027
+ "apps/host/app/page.js": HOST_PAGE_JS,
2028
+ };
2029
+ })(),
2030
+ },
2031
+ // ─── MFE + OpenTelemetry ───────────────────────────────────────────────────
2032
+ {
2033
+ id: "mfe-telemetry",
2034
+ label: "MFE + OpenTelemetry: Distributed Tracing & Perf Budgets",
2035
+ description:
2036
+ "Instrument a host shell and two remotes with a hand-rolled OTEL-compatible tracer. Watch spans appear live in a trace waterfall, enforce render performance budgets, and simulate canary vs stable deployment telemetry.",
2037
+ serverCode: `import express from 'express';
2038
+ const app = express();
2039
+ const port = Number(process.env.PORT);
2040
+ app.get('/api/health', (_req, res) => res.json({ ok: true }));
2041
+ app.listen(port, () => console.log('API :' + port));
2042
+ `,
2043
+ clientCode: "",
2044
+ clientType: "module-federation",
2045
+ reactActiveFile: "apps/host/src/telemetry.js",
2046
+ reactFiles: (() => {
2047
+ // ─── Root ────────────────────────────────────────────────────────────
2048
+ const ROOT_PACKAGE = `{
2049
+ "name": "mfe-telemetry-lab",
2050
+ "private": true,
2051
+ "workspaces": ["apps/host", "apps/profile", "apps/checkout"],
2052
+ "scripts": {
2053
+ "dev": "concurrently -k -n host,profile,checkout -c cyan,magenta,yellow 'npm run dev --workspace=@mf-otel/host' 'npm run dev --workspace=@mf-otel/profile' 'npm run dev --workspace=@mf-otel/checkout'"
2054
+ },
2055
+ "devDependencies": {
2056
+ "concurrently": "^9.2.1"
2057
+ }
2058
+ }
2059
+ `;
2060
+
2061
+ // ─── Shared webpack devDeps (remote apps) ────────────────────────────
2062
+ const REMOTE_DEPS = `{
2063
+ "esbuild-loader": "^4.4.3",
2064
+ "html-webpack-plugin": "^5.6.7",
2065
+ "webpack": "^5.106.2",
2066
+ "webpack-cli": "^7.0.2",
2067
+ "webpack-dev-server": "^5.2.0"
2068
+ }`;
2069
+
2070
+ // ─── Host ─────────────────────────────────────────────────────────────
2071
+ const HOST_PACKAGE = `{
2072
+ "name": "@mf-otel/host",
2073
+ "private": true,
2074
+ "scripts": { "dev": "webpack serve" },
2075
+ "dependencies": {
2076
+ "react": "^18.3.1",
2077
+ "react-dom": "^18.3.1"
2078
+ },
2079
+ "devDependencies": ${REMOTE_DEPS}
2080
+ }
2081
+ `;
2082
+
2083
+ const HOST_WEBPACK = `
2084
+ const path = require('path');
2085
+ const webpack = require('webpack');
2086
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
2087
+ const { ModuleFederationPlugin } = webpack.container;
2088
+
2089
+ const HOST_PORT = Number(process.env.HOST_PORT || 3100);
2090
+ const PROFILE_PORT = Number(process.env.PROFILE_PORT || 3101);
2091
+ const CHECKOUT_PORT = Number(process.env.CHECKOUT_PORT || 3102);
2092
+
2093
+ // ── In-memory span store for the embedded OTLP-like collector ─────────────────
2094
+ // In production: forward to Grafana Alloy / OpenTelemetry Collector / Jaeger.
2095
+ const spanStore = [];
2096
+ const MAX_SPANS = 2000;
2097
+
2098
+ module.exports = {
2099
+ mode: 'development',
2100
+ entry: path.resolve(__dirname, './src/index.js'),
2101
+ output: {
2102
+ path: path.resolve(__dirname, './dist'),
2103
+ publicPath: 'http://localhost:' + HOST_PORT + '/',
2104
+ clean: true,
2105
+ },
2106
+ resolve: { extensions: ['.js', '.jsx'] },
2107
+ module: {
2108
+ rules: [{
2109
+ test: /\\.jsx?$/,
2110
+ exclude: /node_modules/,
2111
+ use: { loader: 'esbuild-loader', options: { loader: 'jsx', jsx: 'automatic', target: 'es2020' } },
2112
+ }],
2113
+ },
2114
+ plugins: [
2115
+ // Inject the collector URL at build time so telemetry.js knows where to POST spans.
2116
+ // Real OTEL: configured in OTLPTraceExporter({ url: '...' })
2117
+ new webpack.DefinePlugin({
2118
+ __COLLECTOR_URL__: JSON.stringify('/api/spans'), // same-origin to host
2119
+ }),
2120
+ new ModuleFederationPlugin({
2121
+ name: 'host',
2122
+ filename: 'remoteEntry.js',
2123
+ // Expose the shared tracer so every remote gets the SAME singleton TracerProvider.
2124
+ // This is the key MFE pattern: shared identity across microfrontend boundaries.
2125
+ exposes: { './telemetry': './src/telemetry.js' },
2126
+ remotes: {
2127
+ profile: 'profile@http://localhost:' + PROFILE_PORT + '/remoteEntry.js',
2128
+ checkout: 'checkout@http://localhost:' + CHECKOUT_PORT + '/remoteEntry.js',
2129
+ },
2130
+ shared: {
2131
+ react: { singleton: true, requiredVersion: '^18.3.1' },
2132
+ 'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
2133
+ },
2134
+ }),
2135
+ new HtmlWebpackPlugin({ template: path.resolve(__dirname, './public/index.html') }),
2136
+ ],
2137
+
2138
+ devServer: {
2139
+ port: HOST_PORT,
2140
+ hot: true,
2141
+ headers: { 'Access-Control-Allow-Origin': '*' },
2142
+
2143
+ // ── Embedded OTLP-like span collector ────────────────────────────────────
2144
+ // webpack-dev-server exposes an Express app via devServer.app.
2145
+ // We intercept /api/spans here so spans go to the same origin as the host page
2146
+ // (no extra server, no extra port, no CORS issues from the host itself).
2147
+ setupMiddlewares(middlewares, devServer) {
2148
+ devServer.app.use('/api/spans', (req, res) => {
2149
+ // Remotes run on different ports so they need CORS to reach this endpoint.
2150
+ res.setHeader('Access-Control-Allow-Origin', '*');
2151
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
2152
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
2153
+ if (req.method === 'OPTIONS') { res.writeHead(204).end(); return; }
2154
+
2155
+ if (req.method === 'GET') {
2156
+ res.setHeader('Content-Type', 'application/json');
2157
+ res.end(JSON.stringify({ spans: spanStore }));
2158
+ return;
2159
+ }
2160
+ if (req.method === 'DELETE') {
2161
+ spanStore.length = 0;
2162
+ res.setHeader('Content-Type', 'application/json');
2163
+ res.end(JSON.stringify({ ok: true }));
2164
+ return;
2165
+ }
2166
+ if (req.method === 'POST') {
2167
+ let body = '';
2168
+ req.on('data', c => { body += c; });
2169
+ req.on('end', () => {
2170
+ try {
2171
+ const data = JSON.parse(body);
2172
+ // Accept both { resourceSpans: [...] } (OTLP-like) and plain arrays
2173
+ const incoming = Array.isArray(data.resourceSpans) ? data.resourceSpans
2174
+ : Array.isArray(data) ? data : [data];
2175
+ spanStore.push(...incoming);
2176
+ if (spanStore.length > MAX_SPANS) spanStore.splice(0, spanStore.length - MAX_SPANS);
2177
+ res.setHeader('Content-Type', 'application/json');
2178
+ res.end(JSON.stringify({ ok: true, total: spanStore.length }));
2179
+ } catch { res.writeHead(400).end('{"error":"bad json"}'); }
2180
+ });
2181
+ return;
2182
+ }
2183
+ res.writeHead(405).end();
2184
+ });
2185
+ return middlewares;
2186
+ },
2187
+ },
2188
+ };
2189
+ `;
2190
+
2191
+ const HOST_HTML = `<!doctype html>
2192
+ <html lang="en">
2193
+ <head>
2194
+ <meta charset="utf-8" />
2195
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2196
+ <title>MFE Telemetry Lab</title>
2197
+ </head>
2198
+ <body style="margin:0">
2199
+ <div id="root"></div>
2200
+ </body>
2201
+ </html>
2202
+ `;
2203
+
2204
+ const HOST_INDEX_JS = `// Async boundary — required for Module Federation shared scope initialisation.
2205
+ // Real OTEL: same pattern in shared-singleton setups.
2206
+ import('./bootstrap');
2207
+ `;
2208
+
2209
+ const HOST_BOOTSTRAP = `import React from 'react';
2210
+ import { createRoot } from 'react-dom/client';
2211
+ import App from './App.jsx';
2212
+ createRoot(document.getElementById('root')).render(React.createElement(App));
2213
+ `;
2214
+
2215
+ // ── THE KEY FILE ─────────────────────────────────────────────────────
2216
+ const HOST_TELEMETRY = `
2217
+ /* ─────────────────────────────────────────────────────────────────────────────
2218
+ * telemetry.js — Lightweight OTEL-compatible tracer for the MFE lab
2219
+ *
2220
+ * API surface mirrors @opentelemetry/api so switching to the real SDK is a
2221
+ * drop-in replacement:
2222
+ *
2223
+ * import { trace } from '@opentelemetry/api';
2224
+ * const tracer = trace.getTracer('mfe-host', '1.0.0');
2225
+ *
2226
+ * KEY CONCEPTS
2227
+ * ─────────────
2228
+ * Span — a named unit of work: has duration, attributes, events, and status.
2229
+ * Trace — a chain of related spans sharing one 128-bit traceId.
2230
+ * Context — propagated between spans via W3C traceparent header.
2231
+ * Export — spans are batch-POSTed to an OTLP HTTP endpoint.
2232
+ * Shared — the host exposes this module; all remotes import the same singleton
2233
+ * so every span has the same TracerProvider and traceId scope.
2234
+ * ───────────────────────────────────────────────────────────────────────────── */
2235
+
2236
+ /* global __COLLECTOR_URL__ */
2237
+ const COLLECTOR_URL =
2238
+ typeof __COLLECTOR_URL__ !== 'undefined' ? __COLLECTOR_URL__ : '/api/spans';
2239
+
2240
+ function rndHex(bytes) {
2241
+ return [...crypto.getRandomValues(new Uint8Array(bytes))]
2242
+ .map(b => b.toString(16).padStart(2, '0'))
2243
+ .join('');
2244
+ }
2245
+
2246
+ // ── Span ──────────────────────────────────────────────────────────────────────
2247
+ // Mirrors the shape of @opentelemetry/sdk-trace-base:ReadableSpan.
2248
+ class Span {
2249
+ constructor({ name, service, traceId, parentSpanId, version, deployEnv }) {
2250
+ // W3C TraceContext fields:
2251
+ // traceId = 128-bit hex — ONE value shared across ALL services in a trace
2252
+ // spanId = 64-bit hex — unique per span
2253
+ // parentSpanId= 64-bit hex — null for root span, links child to parent
2254
+ this.traceId = traceId || rndHex(16);
2255
+ this.spanId = rndHex(8);
2256
+ this.parentSpanId = parentSpanId || null;
2257
+
2258
+ this.name = name;
2259
+ this.service = service;
2260
+ this.startTimeMs = Date.now();
2261
+ this._perfStart = performance.now(); // high-res timer for accurate duration
2262
+
2263
+ // OTEL semantic-convention attributes (prefix keys like 'service.name', 'http.method')
2264
+ this.attributes = {
2265
+ 'service.name': service,
2266
+ 'deployment.version': version || '1.0.0',
2267
+ 'deployment.environment': deployEnv || 'dev',
2268
+ };
2269
+
2270
+ // Events = timestamped annotations within the span (e.g. 'cache.miss', 'retry.1')
2271
+ this.events = [];
2272
+
2273
+ // Status: UNSET → OK (on end) or ERROR (explicit setStatus)
2274
+ this.status = { code: 'UNSET' };
2275
+ this.ended = false;
2276
+ }
2277
+
2278
+ // Add arbitrary key/value metadata.
2279
+ // Best practice: use OpenTelemetry Semantic Convention keys where possible.
2280
+ setAttribute(key, value) { this.attributes[key] = value; return this; }
2281
+
2282
+ // Record a named moment in time. Offset is relative to span start.
2283
+ addEvent(name, attrs = {}) {
2284
+ this.events.push({ name, offsetMs: performance.now() - this._perfStart, attributes: attrs });
2285
+ return this;
2286
+ }
2287
+
2288
+ // Mark this span OK or ERROR.
2289
+ // ERROR spans are flagged in dashboards and can trigger alerts in real systems.
2290
+ setStatus(code, message = '') { this.status = { code, message }; return this; }
2291
+
2292
+ // Finish the span. Computes duration and enqueues it for export.
2293
+ end() {
2294
+ if (this.ended) return this;
2295
+ this.ended = true;
2296
+ this.durationMs = performance.now() - this._perfStart;
2297
+ this.endTimeMs = Date.now();
2298
+ if (this.status.code === 'UNSET') this.setStatus('OK');
2299
+ _enqueue(this.toJSON());
2300
+ return this;
2301
+ }
2302
+
2303
+ // Produce W3C traceparent header string.
2304
+ // Include this on outgoing HTTP requests so backend services can create child spans
2305
+ // under the same traceId — this is what makes traces cross service boundaries.
2306
+ // Format: "00-{32-hex-traceId}-{16-hex-spanId}-01"
2307
+ toTraceparent() { return '00-' + this.traceId + '-' + this.spanId + '-01'; }
2308
+
2309
+ toJSON() {
2310
+ return {
2311
+ traceId: this.traceId, spanId: this.spanId,
2312
+ parentSpanId: this.parentSpanId,
2313
+ name: this.name, service: this.service,
2314
+ startTimeMs: this.startTimeMs, endTimeMs: this.endTimeMs,
2315
+ durationMs: this.durationMs,
2316
+ attributes: { ...this.attributes },
2317
+ events: [...this.events],
2318
+ status: { ...this.status },
2319
+ };
2320
+ }
2321
+ }
2322
+
2323
+ // ── Export pipeline ───────────────────────────────────────────────────────────
2324
+ // Mimics OpenTelemetry's BatchSpanProcessor:
2325
+ // spans accumulate in a queue; a timer flushes them as a batch every 300ms.
2326
+ // Real OTEL:
2327
+ // new BatchSpanProcessor(new OTLPTraceExporter({ url: COLLECTOR_URL }))
2328
+ const _queue = [];
2329
+ let _flushTimer = null;
2330
+
2331
+ function _enqueue(span) {
2332
+ _queue.push(span);
2333
+ if (!_flushTimer) _flushTimer = setTimeout(_flush, 300);
2334
+ }
2335
+
2336
+ async function _flush() {
2337
+ _flushTimer = null;
2338
+ if (!_queue.length) return;
2339
+ const batch = _queue.splice(0);
2340
+ try {
2341
+ // Sends an OTLP-compatible JSON payload.
2342
+ // Real OTLP uses protobuf but the HTTP/JSON format is identical in structure.
2343
+ await fetch(COLLECTOR_URL, {
2344
+ method: 'POST',
2345
+ headers: { 'Content-Type': 'application/json' },
2346
+ body: JSON.stringify({ resourceSpans: batch }),
2347
+ keepalive: true, // span survives page navigation (like visibilitychange export)
2348
+ });
2349
+ } catch {
2350
+ // Never let telemetry failure crash the app — silently drop on network error.
2351
+ }
2352
+ }
2353
+
2354
+ // Flush any remaining spans when the page is hidden (tab switch / close)
2355
+ if (typeof window !== 'undefined') {
2356
+ window.addEventListener('visibilitychange', () => {
2357
+ if (document.visibilityState === 'hidden') _flush();
2358
+ });
2359
+ }
2360
+
2361
+ // ── Tracer ────────────────────────────────────────────────────────────────────
2362
+ // Real OTEL: obtained via trace.getTracer('service-name', '1.0.0')
2363
+ class Tracer {
2364
+ constructor({ serviceName, version, deployEnv }) {
2365
+ this.serviceName = serviceName;
2366
+ this.version = version || '1.0.0';
2367
+ this.deployEnv = deployEnv || 'dev';
2368
+ }
2369
+
2370
+ // Start a span.
2371
+ // Pass parentSpan to create a child span — it inherits the traceId.
2372
+ startSpan(name, { parentSpan, attributes } = {}) {
2373
+ const span = new Span({
2374
+ name,
2375
+ service: this.serviceName,
2376
+ traceId: parentSpan ? parentSpan.traceId : undefined,
2377
+ parentSpanId: parentSpan ? parentSpan.spanId : undefined,
2378
+ version: this.version,
2379
+ deployEnv: this.deployEnv,
2380
+ });
2381
+ if (attributes) Object.entries(attributes).forEach(([k, v]) => span.setAttribute(k, v));
2382
+ return span;
2383
+ }
2384
+
2385
+ // Convenience wrapper: automatically ends span (OK or ERROR) after fn() completes.
2386
+ async withSpan(name, fn, opts = {}) {
2387
+ const span = this.startSpan(name, opts);
2388
+ try { return await fn(span); }
2389
+ catch (err) { span.setStatus('ERROR', err.message); throw err; }
2390
+ finally { span.end(); }
2391
+ }
2392
+
2393
+ // Create a tracer for a remote MFE.
2394
+ // In real OTEL: all MFEs call trace.getTracer() on the SAME global TracerProvider
2395
+ // (set up once by the host). The childTracer pattern approximates this.
2396
+ childTracer(svc) { return new Tracer({ serviceName: svc, version: this.version, deployEnv: this.deployEnv }); }
2397
+
2398
+ // Update version at runtime — used by the canary toggle in App.jsx.
2399
+ setVersion(v) { this.version = v; }
2400
+ setEnv(e) { this.deployEnv = e; }
2401
+ }
2402
+
2403
+ // ── Singleton provider ────────────────────────────────────────────────────────
2404
+ // The host calls initTelemetry() once at startup.
2405
+ // Remotes call getTracer() and receive a child tracer sharing the same provider.
2406
+ // This mirrors how WebTracerProvider works: one provider, many tracers.
2407
+ let _provider = null;
2408
+
2409
+ export function initTelemetry({ serviceName = 'mfe-host', version = '1.0.0', deployEnv = 'dev' } = {}) {
2410
+ _provider = new Tracer({ serviceName, version, deployEnv });
2411
+ if (typeof window !== 'undefined') {
2412
+ // Expose for DevTools exploration: window.__mfeTelemetry.startSpan('test').end()
2413
+ window.__mfeTelemetry = _provider;
2414
+ }
2415
+ return _provider;
2416
+ }
2417
+
2418
+ export function getTracer(serviceName) {
2419
+ if (!_provider) {
2420
+ // Remote loaded before host telemetry initialised — create a fallback provider.
2421
+ // Real OTEL: the global NoopTracerProvider handles this automatically.
2422
+ _provider = new Tracer({ serviceName: 'uninitialized' });
2423
+ }
2424
+ return serviceName ? _provider.childTracer(serviceName) : _provider;
2425
+ }
2426
+
2427
+ // Parse a W3C traceparent header from an incoming request (server-side use)
2428
+ export function parseTraceparent(header) {
2429
+ if (!header) return null;
2430
+ const parts = header.split('-');
2431
+ return parts.length >= 3 ? { traceId: parts[1], spanId: parts[2] } : null;
2432
+ }
2433
+
2434
+ // Force-flush (useful in tests or before page unload)
2435
+ export function flush() { return _flush(); }
2436
+ `;
2437
+
2438
+ // ── App.jsx ──────────────────────────────────────────────────────────
2439
+ const HOST_APP = `
2440
+ import React, { useState, useEffect, Suspense } from 'react';
2441
+ import { initTelemetry } from './telemetry.js';
2442
+ import TraceDashboard from './TraceDashboard.jsx';
2443
+
2444
+ // ── TracerProvider initialisation ────────────────────────────────────────────
2445
+ // This runs once, synchronously, before any component renders.
2446
+ // Real OTEL:
2447
+ // const provider = new WebTracerProvider({ resource: Resource.default().merge(...) });
2448
+ // provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter(...)));
2449
+ // provider.register();
2450
+ const hostTracer = initTelemetry({ serviceName: 'mfe-host', version: '1.0.0' });
2451
+
2452
+ // Lazy-load remotes. React.lazy + Suspense gives us the async boundary.
2453
+ // Wrap each import in a span to measure the actual module-federation resolution time.
2454
+ const ProfileCard = React.lazy(() => {
2455
+ const span = hostTracer.startSpan('host.loadRemote', { attributes: { 'mfe.remote': 'profile' } });
2456
+ return import('profile/ProfileCard')
2457
+ .then(m => { span.setAttribute('mfe.load_status', 'ok').end(); return m; })
2458
+ .catch(e => { span.setStatus('ERROR', e.message).end(); throw e; });
2459
+ });
2460
+
2461
+ const CheckoutPanel = React.lazy(() => {
2462
+ const span = hostTracer.startSpan('host.loadRemote', { attributes: { 'mfe.remote': 'checkout' } });
2463
+ return import('checkout/CheckoutPanel')
2464
+ .then(m => { span.setAttribute('mfe.load_status', 'ok').end(); return m; })
2465
+ .catch(e => { span.setStatus('ERROR', e.message).end(); throw e; });
2466
+ });
2467
+
2468
+ // ── Canary version options ────────────────────────────────────────────────────
2469
+ // In a real canary deploy, 'deployment.version' lets you split span queries:
2470
+ // error_rate{deployment_version="2.0.0"} > error_rate{deployment_version="1.0.0"}
2471
+ // → rollback automatically if canary is worse
2472
+ const VERSIONS = [
2473
+ { value: '1.0.0', label: 'v1.0.0 stable', note: '' },
2474
+ { value: '2.0.0', label: 'v2.0.0 canary 🐦', note: 'Emits higher error rate in checkout' },
2475
+ ];
2476
+
2477
+ export default function App() {
2478
+ const [version, setVersion] = useState('1.0.0');
2479
+ const [showProfile, setShowProfile] = useState(false);
2480
+ const [showCheckout, setShowCheckout] = useState(false);
2481
+
2482
+ // Push version change through to all tracers so every new span gets the right tag
2483
+ useEffect(() => { hostTracer.setVersion(version); }, [version]);
2484
+
2485
+ function handleActivateRemote(name) {
2486
+ const span = hostTracer.startSpan('host.activateRemote', {
2487
+ attributes: { 'mfe.remote': name, 'user.action': 'click' },
2488
+ });
2489
+ span.addEvent('remote.activation.requested');
2490
+ // The actual remote load time is recorded inside the React.lazy thunk above
2491
+ if (name === 'profile') setShowProfile(true);
2492
+ if (name === 'checkout') setShowCheckout(true);
2493
+ span.end();
2494
+ }
2495
+
2496
+ function handleApiCall() {
2497
+ // Demonstrates W3C traceparent propagation to a backend service
2498
+ const span = hostTracer.startSpan('host.apiCall', {
2499
+ attributes: { 'http.method': 'GET', 'http.url': '/api/user/me' },
2500
+ });
2501
+ const traceparent = span.toTraceparent();
2502
+ // In real code you'd pass this header on the fetch():
2503
+ // fetch('/api/user/me', { headers: { traceparent } })
2504
+ // The backend creates a CHILD span with parentSpanId = this span's spanId.
2505
+ console.log('[Telemetry] Outbound fetch headers:');
2506
+ console.log(' traceparent:', traceparent);
2507
+ console.log(' → backend would emit a child span under traceId:', span.traceId);
2508
+ span.addEvent('fetch.started', { 'http.url': '/api/user/me' });
2509
+ setTimeout(() => {
2510
+ span.setAttribute('http.status_code', 200).addEvent('fetch.completed').end();
2511
+ }, 120);
2512
+ }
2513
+
2514
+ return (
2515
+ <div style={{ minHeight: '100vh', background: 'linear-gradient(135deg,#0f172a,#1e293b)', color: '#e2e8f0', fontFamily: 'ui-sans-serif,system-ui,sans-serif', padding: '1.5rem' }}>
2516
+
2517
+ {/* Header */}
2518
+ <div style={{ maxWidth: '1200px', margin: '0 auto' }}>
2519
+ <p style={{ margin: '0 0 0.25rem', color: '#38bdf8', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
2520
+ OpenTelemetry · Module Federation
2521
+ </p>
2522
+ <h1 style={{ margin: '0 0 0.5rem', fontSize: '1.6rem', fontWeight: 700 }}>
2523
+ MFE Distributed Tracing Lab
2524
+ </h1>
2525
+ <p style={{ margin: '0 0 1.5rem', color: '#94a3b8', fontSize: '0.875rem', maxWidth: '60ch' }}>
2526
+ Each button below creates a span. Spans from all three microfrontends share one
2527
+ <code style={{ background: '#1e293b', padding: '0 4px', borderRadius: '3px' }}>traceId</code>
2528
+ so the dashboard below can show the full trace waterfall.
2529
+ </p>
2530
+
2531
+ {/* Canary toggle */}
2532
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.75rem', background: '#1e293b', border: '1px solid #334155', borderRadius: '0.75rem', padding: '0.6rem 1rem', marginBottom: '1.5rem' }}>
2533
+ <span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>Deployment version:</span>
2534
+ <select
2535
+ value={version}
2536
+ onChange={e => setVersion(e.target.value)}
2537
+ style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #475569', borderRadius: '0.4rem', padding: '0.25rem 0.5rem', fontSize: '0.8rem', cursor: 'pointer' }}
2538
+ >
2539
+ {VERSIONS.map(v => (
2540
+ <option key={v.value} value={v.value}>{v.label}</option>
2541
+ ))}
2542
+ </select>
2543
+ {version === '2.0.0' && (
2544
+ <span style={{ fontSize: '0.7rem', color: '#f59e0b', background: '#451a03', padding: '2px 8px', borderRadius: '999px' }}>
2545
+ checkout simulates 20% error rate — watch dashboard for red spans
2546
+ </span>
2547
+ )}
2548
+ </div>
2549
+
2550
+ {/* Action row */}
2551
+ <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
2552
+ {[
2553
+ { label: showProfile ? 'Profile loaded ✓' : 'Load Profile Remote', action: () => handleActivateRemote('profile'), active: showProfile },
2554
+ { label: showCheckout ? 'Checkout loaded ✓' : 'Load Checkout Remote', action: () => handleActivateRemote('checkout'), active: showCheckout },
2555
+ { label: 'Make API call (see console)', action: handleApiCall, active: false },
2556
+ ].map(btn => (
2557
+ <button
2558
+ key={btn.label}
2559
+ onClick={btn.action}
2560
+ disabled={btn.active}
2561
+ style={{ padding: '0.5rem 1rem', borderRadius: '0.5rem', border: 'none', cursor: btn.active ? 'default' : 'pointer', fontWeight: 600, fontSize: '0.8rem', background: btn.active ? '#1e293b' : '#0ea5e9', color: btn.active ? '#64748b' : '#fff', transition: 'opacity 0.15s' }}
2562
+ >
2563
+ {btn.label}
2564
+ </button>
2565
+ ))}
2566
+ </div>
2567
+
2568
+ {/* Explainer */}
2569
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(260px,1fr))', gap: '0.75rem', marginBottom: '1.5rem' }}>
2570
+ {[
2571
+ { title: 'Span', body: 'A named unit of work with start/end time, attributes, and status. Created by startSpan(), finished by span.end().' },
2572
+ { title: 'Trace', body: 'A group of spans sharing one traceId. Makes it trivial to see what happened across host + all remotes for one user action.' },
2573
+ { title: 'W3C traceparent', body: 'The traceparent header carries traceId+spanId across HTTP boundaries. Backend creates child spans under the same traceId.' },
2574
+ { title: 'Performance budget', body: 'checkout enforces a 50ms render budget. Exceed it → span gets status ERROR so dashboards and alerts fire. Canary v2 can simulate this.' },
2575
+ ].map(c => (
2576
+ <div key={c.title} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '0.75rem', padding: '0.75rem 1rem' }}>
2577
+ <p style={{ margin: '0 0 0.25rem', color: '#38bdf8', fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase' }}>{c.title}</p>
2578
+ <p style={{ margin: 0, color: '#94a3b8', fontSize: '0.78rem', lineHeight: 1.5 }}>{c.body}</p>
2579
+ </div>
2580
+ ))}
2581
+ </div>
2582
+
2583
+ {/* Remote panels */}
2584
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(320px,1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
2585
+ <Suspense fallback={<RemotePlaceholder name="profile" />}>
2586
+ {showProfile && <ProfileCard deploymentVersion={version} />}
2587
+ </Suspense>
2588
+ <Suspense fallback={<RemotePlaceholder name="checkout" />}>
2589
+ {showCheckout && <CheckoutPanel deploymentVersion={version} />}
2590
+ </Suspense>
2591
+ </div>
2592
+
2593
+ {/* Live trace dashboard */}
2594
+ <TraceDashboard />
2595
+ </div>
2596
+ </div>
2597
+ );
2598
+ }
2599
+
2600
+ function RemotePlaceholder({ name }) {
2601
+ return (
2602
+ <div style={{ background: '#1e293b', border: '1px dashed #334155', borderRadius: '0.75rem', padding: '1.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
2603
+ <span style={{ color: '#475569', fontSize: '0.8rem' }}>Loading {name} remote…</span>
2604
+ </div>
2605
+ );
2606
+ }
2607
+ `;
2608
+
2609
+ // ── TraceDashboard.jsx ────────────────────────────────────────────────
2610
+ const HOST_TRACE_DASHBOARD = `
2611
+ import React, { useState, useEffect, useRef } from 'react';
2612
+
2613
+ function groupByTrace(spans) {
2614
+ const map = new Map();
2615
+ for (const s of spans) {
2616
+ if (!map.has(s.traceId)) map.set(s.traceId, []);
2617
+ map.get(s.traceId).push(s);
2618
+ }
2619
+ // Sort traces: most recent first
2620
+ return [...map.entries()]
2621
+ .map(([traceId, spans]) => ({ traceId, spans: spans.sort((a, b) => a.startTimeMs - b.startTimeMs) }))
2622
+ .sort((a, b) => b.spans[0].startTimeMs - a.spans[0].startTimeMs);
2623
+ }
2624
+
2625
+ const SVC_COLORS = {
2626
+ 'mfe-host': '#38bdf8',
2627
+ 'profile-remote':'#a78bfa',
2628
+ 'checkout-remote':'#fb923c',
2629
+ };
2630
+ function svcColor(svc) { return SVC_COLORS[svc] || '#94a3b8'; }
2631
+
2632
+ export default function TraceDashboard() {
2633
+ const [spans, setSpans] = useState([]);
2634
+ const [autoRefresh, setAutoRefresh] = useState(true);
2635
+ const [expanded, setExpanded] = useState(null); // traceId of expanded row
2636
+ const [selected, setSelected] = useState(null); // clicked span for detail
2637
+ const endRef = useRef(null);
2638
+
2639
+ useEffect(() => {
2640
+ if (!autoRefresh) return;
2641
+ const id = setInterval(async () => {
2642
+ try {
2643
+ const data = await fetch('/api/spans').then(r => r.json());
2644
+ setSpans(data.spans || []);
2645
+ } catch {}
2646
+ }, 800);
2647
+ return () => clearInterval(id);
2648
+ }, [autoRefresh]);
2649
+
2650
+ const clearAll = async () => {
2651
+ await fetch('/api/spans', { method: 'DELETE' });
2652
+ setSpans([]); setExpanded(null); setSelected(null);
2653
+ };
2654
+
2655
+ const exportSpans = () => {
2656
+ const blob = new Blob([JSON.stringify(spans, null, 2)], { type: 'application/json' });
2657
+ const url = URL.createObjectURL(blob);
2658
+ const a = document.createElement('a');
2659
+ a.href = url; a.download = 'spans.json'; a.click();
2660
+ URL.revokeObjectURL(url);
2661
+ };
2662
+
2663
+ const traces = groupByTrace(spans);
2664
+ const errCount = spans.filter(s => s.status?.code === 'ERROR').length;
2665
+
2666
+ return (
2667
+ <div style={{ background: '#0f172a', border: '1px solid #1e293b', borderRadius: '1rem', padding: '1rem', fontSize: '0.8rem' }}>
2668
+ {/* Toolbar */}
2669
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
2670
+ <span style={{ color: '#38bdf8', fontWeight: 700, fontSize: '0.85rem' }}>🔭 Live Trace Dashboard</span>
2671
+ <span style={{ color: '#64748b' }}>{spans.length} spans · {traces.length} traces</span>
2672
+ {errCount > 0 && (
2673
+ <span style={{ background: '#450a0a', color: '#f87171', padding: '2px 8px', borderRadius: '999px', fontSize: '0.7rem', fontWeight: 700 }}>
2674
+ {errCount} ERROR{errCount > 1 ? 'S' : ''}
2675
+ </span>
2676
+ )}
2677
+ <div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
2678
+ <TinyBtn onClick={() => setAutoRefresh(a => !a)} label={autoRefresh ? '⏸ Pause' : '▶ Resume'} />
2679
+ <TinyBtn onClick={clearAll} label="🗑 Clear" />
2680
+ <TinyBtn onClick={exportSpans} label="⬇ Export JSON" />
2681
+ </div>
2682
+ </div>
2683
+
2684
+ {/* Legend */}
2685
+ <div style={{ display: 'flex', gap: '1rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
2686
+ {Object.entries(SVC_COLORS).map(([svc, col]) => (
2687
+ <span key={svc} style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#94a3b8', fontSize: '0.7rem' }}>
2688
+ <span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: 2, background: col }} />
2689
+ {svc}
2690
+ </span>
2691
+ ))}
2692
+ <span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#94a3b8', fontSize: '0.7rem' }}>
2693
+ <span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: 2, background: '#ef4444' }} />
2694
+ ERROR / budget exceeded
2695
+ </span>
2696
+ </div>
2697
+
2698
+ {traces.length === 0 ? (
2699
+ <p style={{ color: '#334155', fontStyle: 'italic', textAlign: 'center', padding: '2rem 0' }}>
2700
+ No spans yet — click a button above to generate some.
2701
+ </p>
2702
+ ) : (
2703
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
2704
+ {traces.map(trace => (
2705
+ <TraceRow
2706
+ key={trace.traceId}
2707
+ trace={trace}
2708
+ isExpanded={expanded === trace.traceId}
2709
+ selected={selected}
2710
+ onToggle={() => setExpanded(e => e === trace.traceId ? null : trace.traceId)}
2711
+ onSelectSpan={setSelected}
2712
+ />
2713
+ ))}
2714
+ </div>
2715
+ )}
2716
+ <div ref={endRef} />
2717
+ </div>
2718
+ );
2719
+ }
2720
+
2721
+ function TraceRow({ trace, isExpanded, selected, onToggle, onSelectSpan }) {
2722
+ const firstMs = trace.spans[0].startTimeMs;
2723
+ const lastMs = Math.max(...trace.spans.map(s => s.endTimeMs || s.startTimeMs));
2724
+ const traceDurMs = Math.max(lastMs - firstMs, 1);
2725
+ const hasError = trace.spans.some(s => s.status?.code === 'ERROR');
2726
+ const services = [...new Set(trace.spans.map(s => s.service))];
2727
+
2728
+ return (
2729
+ <div style={{ border: '1px solid ' + (hasError ? '#7f1d1d' : '#1e293b'), borderRadius: '0.5rem', overflow: 'hidden' }}>
2730
+ <div
2731
+ onClick={onToggle}
2732
+ style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.4rem 0.75rem', background: hasError ? '#1c0a0a' : '#0f172a', cursor: 'pointer', userSelect: 'none' }}
2733
+ >
2734
+ <span style={{ color: '#475569', fontSize: '0.65rem' }}>{isExpanded ? '▼' : '▶'}</span>
2735
+ <code style={{ color: hasError ? '#f87171' : '#38bdf8', fontSize: '0.72rem' }}>
2736
+ {trace.traceId.slice(0, 8)}…
2737
+ </code>
2738
+ <span style={{ color: '#64748b', fontSize: '0.7rem' }}>
2739
+ {trace.spans.length} span{trace.spans.length > 1 ? 's' : ''}
2740
+ </span>
2741
+ <div style={{ display: 'flex', gap: '3px' }}>
2742
+ {services.map(svc => (
2743
+ <span key={svc} style={{ background: svcColor(svc) + '33', color: svcColor(svc), padding: '1px 6px', borderRadius: '999px', fontSize: '0.6rem', fontWeight: 600 }}>
2744
+ {svc.replace('-remote', '')}
2745
+ </span>
2746
+ ))}
2747
+ </div>
2748
+ <span style={{ color: '#475569', fontSize: '0.7rem', marginLeft: 'auto' }}>
2749
+ {traceDurMs.toFixed(0)}ms total
2750
+ </span>
2751
+ {hasError && (
2752
+ <span style={{ color: '#ef4444', fontSize: '0.7rem', fontWeight: 700 }}>⚠ ERROR</span>
2753
+ )}
2754
+ </div>
2755
+
2756
+ {isExpanded && (
2757
+ <div style={{ padding: '0.5rem 0.75rem', background: '#0b1120', display: 'flex', flexDirection: 'column', gap: '4px' }}>
2758
+ {trace.spans.map(span => {
2759
+ const left = ((span.startTimeMs - firstMs) / traceDurMs) * 100;
2760
+ const width = Math.max(((span.durationMs || 0) / traceDurMs) * 100, 1);
2761
+ const isErr = span.status?.code === 'ERROR';
2762
+ const bar = isErr ? '#ef4444' : span.parentSpanId ? svcColor(span.service) : '#22c55e';
2763
+ const isSel = selected?.spanId === span.spanId;
2764
+
2765
+ return (
2766
+ <div key={span.spanId} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }} onClick={() => onSelectSpan(isSel ? null : span)}>
2767
+ <span style={{ width: '22ch', flexShrink: 0, fontFamily: 'monospace', fontSize: '0.65rem', color: svcColor(span.service), overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
2768
+ {span.service.replace('-remote','')}:{span.name}
2769
+ </span>
2770
+ <div style={{ flex: 1, height: '12px', background: '#1e293b', borderRadius: '2px', position: 'relative' }}>
2771
+ <div style={{ position: 'absolute', left: left + '%', width: width + '%', height: '100%', background: bar, borderRadius: '2px', minWidth: '4px', border: isSel ? '1px solid #fff' : 'none' }}
2772
+ title={span.name + ': ' + (span.durationMs || 0).toFixed(1) + 'ms'} />
2773
+ </div>
2774
+ <span style={{ width: '6ch', textAlign: 'right', fontFamily: 'monospace', fontSize: '0.65rem', color: isErr ? '#ef4444' : '#64748b', flexShrink: 0 }}>
2775
+ {(span.durationMs || 0).toFixed(1)}ms
2776
+ </span>
2777
+ </div>
2778
+ );
2779
+ })}
2780
+
2781
+ {/* Selected span detail panel */}
2782
+ {selected && trace.spans.find(s => s.spanId === selected.spanId) && (
2783
+ <SpanDetail span={selected} onClose={() => onSelectSpan(null)} />
2784
+ )}
2785
+ </div>
2786
+ )}
2787
+ </div>
2788
+ );
2789
+ }
2790
+
2791
+ function SpanDetail({ span, onClose }) {
2792
+ return (
2793
+ <div style={{ marginTop: '0.5rem', background: '#1e293b', border: '1px solid #334155', borderRadius: '0.5rem', padding: '0.75rem', fontSize: '0.7rem' }}>
2794
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
2795
+ <strong style={{ color: '#e2e8f0' }}>{span.name}</strong>
2796
+ <button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: '0.8rem' }}>✕</button>
2797
+ </div>
2798
+ <div style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '3px 12px', color: '#94a3b8' }}>
2799
+ <span>traceId</span><code style={{ color: '#38bdf8', wordBreak: 'break-all' }}>{span.traceId}</code>
2800
+ <span>spanId</span><code style={{ color: '#a78bfa' }}>{span.spanId}</code>
2801
+ {span.parentSpanId && <><span>parentSpanId</span><code style={{ color: '#64748b' }}>{span.parentSpanId}</code></>}
2802
+ <span>traceparent</span><code style={{ color: '#94a3b8', fontSize: '0.6rem', wordBreak: 'break-all' }}>{'00-' + span.traceId + '-' + span.spanId + '-01'}</code>
2803
+ <span>duration</span><span style={{ color: span.status?.code === 'ERROR' ? '#ef4444' : '#22c55e' }}>{(span.durationMs || 0).toFixed(2)}ms</span>
2804
+ <span>status</span>
2805
+ <span style={{ color: span.status?.code === 'ERROR' ? '#ef4444' : span.status?.code === 'OK' ? '#22c55e' : '#94a3b8' }}>
2806
+ {span.status?.code}{span.status?.message ? ' — ' + span.status.message : ''}
2807
+ </span>
2808
+ </div>
2809
+ {Object.keys(span.attributes || {}).length > 0 && (
2810
+ <div style={{ marginTop: '0.5rem' }}>
2811
+ <p style={{ margin: '0 0 3px', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.06em', fontSize: '0.6rem' }}>Attributes</p>
2812
+ {Object.entries(span.attributes).map(([k, v]) => (
2813
+ <div key={k} style={{ display: 'flex', gap: '8px' }}>
2814
+ <code style={{ color: '#64748b', flexShrink: 0 }}>{k}</code>
2815
+ <code style={{ color: '#e2e8f0' }}>{String(v)}</code>
2816
+ </div>
2817
+ ))}
2818
+ </div>
2819
+ )}
2820
+ {(span.events || []).length > 0 && (
2821
+ <div style={{ marginTop: '0.5rem' }}>
2822
+ <p style={{ margin: '0 0 3px', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.06em', fontSize: '0.6rem' }}>Events</p>
2823
+ {span.events.map((e, i) => (
2824
+ <div key={i} style={{ display: 'flex', gap: '8px' }}>
2825
+ <code style={{ color: '#64748b' }}>{e.offsetMs?.toFixed(1)}ms</code>
2826
+ <code style={{ color: '#e2e8f0' }}>{e.name}</code>
2827
+ </div>
2828
+ ))}
2829
+ </div>
2830
+ )}
2831
+ </div>
2832
+ );
2833
+ }
2834
+
2835
+ function TinyBtn({ onClick, label }) {
2836
+ return (
2837
+ <button onClick={onClick} style={{ padding: '3px 10px', borderRadius: '0.35rem', border: '1px solid #334155', background: '#1e293b', color: '#94a3b8', cursor: 'pointer', fontSize: '0.72rem' }}>
2838
+ {label}
2839
+ </button>
2840
+ );
2841
+ }
2842
+ `;
2843
+
2844
+ // ─── Profile remote ───────────────────────────────────────────────────
2845
+ const PROFILE_PACKAGE = `{
2846
+ "name": "@mf-otel/profile",
2847
+ "private": true,
2848
+ "scripts": { "dev": "webpack serve" },
2849
+ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
2850
+ "devDependencies": ${REMOTE_DEPS}
2851
+ }
2852
+ `;
2853
+
2854
+ const PROFILE_WEBPACK = `
2855
+ const path = require('path');
2856
+ const webpack = require('webpack');
2857
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
2858
+ const { ModuleFederationPlugin } = webpack.container;
2859
+
2860
+ const HOST_PORT = Number(process.env.HOST_PORT || 3100);
2861
+ const PROFILE_PORT = Number(process.env.PROFILE_PORT || 3101);
2862
+
2863
+ module.exports = {
2864
+ mode: 'development',
2865
+ entry: path.resolve(__dirname, './src/index.js'),
2866
+ output: {
2867
+ path: path.resolve(__dirname, './dist'),
2868
+ publicPath: 'http://localhost:' + PROFILE_PORT + '/',
2869
+ clean: true,
2870
+ },
2871
+ resolve: { extensions: ['.js', '.jsx'] },
2872
+ module: {
2873
+ rules: [{
2874
+ test: /\\.jsx?$/, exclude: /node_modules/,
2875
+ use: { loader: 'esbuild-loader', options: { loader: 'jsx', jsx: 'automatic', target: 'es2020' } },
2876
+ }],
2877
+ },
2878
+ plugins: [
2879
+ new ModuleFederationPlugin({
2880
+ name: 'profile',
2881
+ filename: 'remoteEntry.js',
2882
+ exposes: { './ProfileCard': './src/ProfileCard.jsx' },
2883
+ // Import the host's shared telemetry module so this remote's spans
2884
+ // are created by the SAME TracerProvider as the host.
2885
+ remotes: { host: 'host@http://localhost:' + HOST_PORT + '/remoteEntry.js' },
2886
+ shared: {
2887
+ react: { singleton: true, requiredVersion: '^18.3.1' },
2888
+ 'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
2889
+ },
2890
+ }),
2891
+ new HtmlWebpackPlugin({ template: path.resolve(__dirname, './public/index.html') }),
2892
+ ],
2893
+ devServer: {
2894
+ port: PROFILE_PORT,
2895
+ hot: true,
2896
+ headers: { 'Access-Control-Allow-Origin': '*' },
2897
+ },
2898
+ };
2899
+ `;
2900
+
2901
+ const REMOTE_HTML = (title: string) => `<!doctype html>
2902
+ <html lang="en">
2903
+ <head><meta charset="utf-8" /><title>${title}</title></head>
2904
+ <body style="margin:0"><div id="root"></div></body>
2905
+ </html>
2906
+ `;
2907
+ const REMOTE_INDEX = `import('./bootstrap');`;
2908
+ const REMOTE_BOOTSTRAP = (component: string) => `
2909
+ import React from 'react';
2910
+ import { createRoot } from 'react-dom/client';
2911
+ import ${component} from './${component}.jsx';
2912
+ const root = document.getElementById('root');
2913
+ if (root) createRoot(root).render(React.createElement(${component}));
2914
+ `;
2915
+
2916
+ const PROFILE_CARD = `
2917
+ import React, { useEffect, useRef } from 'react';
2918
+ // Import telemetry from the HOST's singleton provider via Module Federation.
2919
+ // This is the key pattern: every MFE shares one TracerProvider so all spans
2920
+ // belong to the same trace when triggered by the same user action.
2921
+ import { getTracer } from 'host/telemetry';
2922
+
2923
+ const tracer = getTracer('profile-remote');
2924
+
2925
+ export default function ProfileCard({ deploymentVersion }) {
2926
+ // Start the render span synchronously (outside useEffect) so startTimeMs
2927
+ // is captured before the first render pass, not after paint.
2928
+ const spanRef = useRef(null);
2929
+ if (!spanRef.current) {
2930
+ spanRef.current = tracer.startSpan('profile.render', {
2931
+ attributes: {
2932
+ 'component': 'ProfileCard',
2933
+ 'mfe.name': 'profile',
2934
+ },
2935
+ });
2936
+ }
2937
+
2938
+ useEffect(() => {
2939
+ // Component mounted — end the render span now we have a real DOM node.
2940
+ spanRef.current
2941
+ .setAttribute('deploy.version', deploymentVersion || '1.0.0')
2942
+ .addEvent('component.mounted')
2943
+ .setStatus('OK')
2944
+ .end();
2945
+ }, []);
2946
+
2947
+ function handleInteraction(action) {
2948
+ // Child span — inherits the trace by passing parentSpan.
2949
+ // Without parentSpan, this would start a NEW trace (a common mistake).
2950
+ const parent = spanRef.current;
2951
+ const span = tracer.startSpan('profile.interaction', {
2952
+ parentSpan: parent.ended ? null : parent,
2953
+ attributes: { 'user.action': action },
2954
+ });
2955
+ span.addEvent('click.handled').end();
2956
+ }
2957
+
2958
+ return (
2959
+ <div style={{ background: 'linear-gradient(135deg,#2e1065,#1e1b4b)', border: '1px solid #5b21b6', borderRadius: '0.75rem', padding: '1.25rem', fontFamily: 'ui-sans-serif,system-ui,sans-serif' }}>
2960
+ <p style={{ margin: '0 0 0.25rem', color: '#a78bfa', fontSize: '0.65rem', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
2961
+ profile remote · instrumented with OTEL-compatible tracer
2962
+ </p>
2963
+ <h2 style={{ margin: '0 0 0.75rem', color: '#ede9fe', fontSize: '1.1rem' }}>
2964
+ User Profile
2965
+ </h2>
2966
+ <p style={{ margin: '0 0 1rem', color: '#c4b5fd', fontSize: '0.82rem', lineHeight: 1.5 }}>
2967
+ This component started a span when it mounted. The span's traceId matches the
2968
+ host's "loadRemote" span — same trace, different service.
2969
+ </p>
2970
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
2971
+ {['View profile', 'Edit avatar', 'Sign out'].map(action => (
2972
+ <button
2973
+ key={action}
2974
+ onClick={() => handleInteraction(action)}
2975
+ style={{ padding: '0.3rem 0.75rem', background: '#4c1d95', border: '1px solid #6d28d9', borderRadius: '0.4rem', color: '#ddd6fe', fontSize: '0.75rem', cursor: 'pointer' }}
2976
+ >
2977
+ {action}
2978
+ </button>
2979
+ ))}
2980
+ </div>
2981
+ <p style={{ margin: '0.75rem 0 0', color: '#6d28d9', fontSize: '0.7rem' }}>
2982
+ Each button click creates a child span → check the dashboard
2983
+ </p>
2984
+ </div>
2985
+ );
2986
+ }
2987
+ `;
2988
+
2989
+ // ─── Checkout remote ──────────────────────────────────────────────────
2990
+ const CHECKOUT_PACKAGE = `{
2991
+ "name": "@mf-otel/checkout",
2992
+ "private": true,
2993
+ "scripts": { "dev": "webpack serve" },
2994
+ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
2995
+ "devDependencies": ${REMOTE_DEPS}
2996
+ }
2997
+ `;
2998
+
2999
+ const CHECKOUT_WEBPACK = `
3000
+ const path = require('path');
3001
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
3002
+ const webpack = require('webpack');
3003
+ const { ModuleFederationPlugin } = webpack.container;
3004
+
3005
+ const HOST_PORT = Number(process.env.HOST_PORT || 3100);
3006
+ const CHECKOUT_PORT = Number(process.env.CHECKOUT_PORT || 3102);
3007
+
3008
+ module.exports = {
3009
+ mode: 'development',
3010
+ entry: path.resolve(__dirname, './src/index.js'),
3011
+ output: {
3012
+ path: path.resolve(__dirname, './dist'),
3013
+ publicPath: 'http://localhost:' + CHECKOUT_PORT + '/',
3014
+ clean: true,
3015
+ },
3016
+ resolve: { extensions: ['.js', '.jsx'] },
3017
+ module: {
3018
+ rules: [{
3019
+ test: /\\.jsx?$/, exclude: /node_modules/,
3020
+ use: { loader: 'esbuild-loader', options: { loader: 'jsx', jsx: 'automatic', target: 'es2020' } },
3021
+ }],
3022
+ },
3023
+ plugins: [
3024
+ new ModuleFederationPlugin({
3025
+ name: 'checkout',
3026
+ filename: 'remoteEntry.js',
3027
+ exposes: { './CheckoutPanel': './src/CheckoutPanel.jsx' },
3028
+ remotes: { host: 'host@http://localhost:' + HOST_PORT + '/remoteEntry.js' },
3029
+ shared: {
3030
+ react: { singleton: true, requiredVersion: '^18.3.1' },
3031
+ 'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
3032
+ },
3033
+ }),
3034
+ new HtmlWebpackPlugin({ template: path.resolve(__dirname, './public/index.html') }),
3035
+ ],
3036
+ devServer: {
3037
+ port: CHECKOUT_PORT,
3038
+ hot: true,
3039
+ headers: { 'Access-Control-Allow-Origin': '*' },
3040
+ },
3041
+ };
3042
+ `;
3043
+
3044
+ const CHECKOUT_PANEL = `
3045
+ import React, { useEffect, useRef, useState } from 'react';
3046
+ import { getTracer } from 'host/telemetry';
3047
+
3048
+ const tracer = getTracer('checkout-remote');
3049
+
3050
+ // ── Performance budget ────────────────────────────────────────────────────────
3051
+ // Any render exceeding BUDGET_MS gets an ERROR span.
3052
+ // Lower this to 10ms to always trigger it. Raise to 200 to never trigger normally.
3053
+ // This is how teams enforce "checkout must render in < 50ms" at the telemetry level.
3054
+ const BUDGET_MS = 50;
3055
+
3056
+ export default function CheckoutPanel({ deploymentVersion }) {
3057
+ const renderSpanRef = useRef(null);
3058
+ const mountPerfRef = useRef(performance.now());
3059
+ const [items, setItems] = useState([{ name: 'Laptop', qty: 1, price: 999 }]);
3060
+ const [slowResult, setSlowResult] = useState(null);
3061
+
3062
+ if (!renderSpanRef.current) {
3063
+ renderSpanRef.current = tracer.startSpan('checkout.render', {
3064
+ attributes: { 'component': 'CheckoutPanel', 'mfe.name': 'checkout' },
3065
+ });
3066
+ }
3067
+
3068
+ useEffect(() => {
3069
+ const durationMs = performance.now() - mountPerfRef.current;
3070
+ const span = renderSpanRef.current;
3071
+
3072
+ span.setAttribute('render.duration_ms', durationMs);
3073
+ span.setAttribute('perf.budget_ms', BUDGET_MS);
3074
+ span.setAttribute('deploy.version', deploymentVersion || '1.0.0');
3075
+ span.addEvent('component.mounted', { duration_ms: durationMs });
3076
+
3077
+ // ── PERFORMANCE BUDGET ENFORCEMENT ────────────────────────────────────
3078
+ if (durationMs > BUDGET_MS) {
3079
+ span.setStatus('ERROR',
3080
+ 'Render exceeded ' + BUDGET_MS + 'ms budget (actual: ' + durationMs.toFixed(1) + 'ms)'
3081
+ );
3082
+ span.setAttribute('perf.budget_exceeded', true);
3083
+ } else {
3084
+ span.setStatus('OK');
3085
+ span.setAttribute('perf.budget_exceeded', false);
3086
+ }
3087
+ span.end();
3088
+ }, []);
3089
+
3090
+ function simulateSlowRender() {
3091
+ // Artificially block the JS thread to guarantee a budget violation next render.
3092
+ setSlowResult(null);
3093
+ const parent = tracer.startSpan('checkout.slowRenderTest', {
3094
+ attributes: { 'test.type': 'perf_budget_violation' },
3095
+ });
3096
+ parent.addEvent('blocking.started');
3097
+
3098
+ // Synchronous busy-wait to simulate real slow work (DOM layout, heavy computation)
3099
+ const start = performance.now();
3100
+ while (performance.now() - start < BUDGET_MS + 30) { /* block */ }
3101
+
3102
+ const elapsed = performance.now() - start;
3103
+ parent
3104
+ .setAttribute('render.simulated_duration_ms', elapsed)
3105
+ .setStatus('ERROR', 'Intentional slow render: ' + elapsed.toFixed(1) + 'ms > ' + BUDGET_MS + 'ms budget')
3106
+ .addEvent('blocking.ended', { elapsed_ms: elapsed })
3107
+ .end();
3108
+
3109
+ setSlowResult(elapsed.toFixed(1) + 'ms — ERROR span emitted, check dashboard');
3110
+ }
3111
+
3112
+ function simulateCanaryError() {
3113
+ // In canary v2, we simulate a 20% error rate on checkout.submit
3114
+ const isError = deploymentVersion === '2.0.0' && Math.random() < 0.20;
3115
+ const span = tracer.startSpan('checkout.submit', {
3116
+ attributes: {
3117
+ 'cart.items_count': items.length,
3118
+ 'cart.total_value': items.reduce((s, i) => s + i.price * i.qty, 0),
3119
+ 'deploy.version': deploymentVersion || '1.0.0',
3120
+ },
3121
+ });
3122
+ span.addEvent('payment.initiated');
3123
+ setTimeout(() => {
3124
+ if (isError) {
3125
+ span
3126
+ .setStatus('ERROR', 'Payment gateway timeout (simulated canary regression)')
3127
+ .addEvent('payment.failed', { reason: 'gateway_timeout' })
3128
+ .end();
3129
+ } else {
3130
+ span.addEvent('payment.completed').setStatus('OK').end();
3131
+ }
3132
+ }, 80 + Math.random() * 120);
3133
+ }
3134
+
3135
+ const total = items.reduce((s, i) => s + i.price * i.qty, 0);
3136
+
3137
+ return (
3138
+ <div style={{ background: 'linear-gradient(135deg,#431407,#1c0a02)', border: '1px solid #9a3412', borderRadius: '0.75rem', padding: '1.25rem', fontFamily: 'ui-sans-serif,system-ui,sans-serif' }}>
3139
+ <p style={{ margin: '0 0 0.25rem', color: '#fb923c', fontSize: '0.65rem', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
3140
+ checkout remote · {BUDGET_MS}ms render budget · {deploymentVersion === '2.0.0' ? '⚠ canary v2 — 20% error rate' : 'stable v1'}
3141
+ </p>
3142
+ <h2 style={{ margin: '0 0 0.75rem', color: '#fed7aa', fontSize: '1.1rem' }}>Cart</h2>
3143
+
3144
+ {items.map((item, i) => (
3145
+ <div key={i} style={{ display: 'flex', justifyContent: 'space-between', color: '#fdba74', fontSize: '0.82rem', marginBottom: '0.3rem' }}>
3146
+ <span>{item.name} × {item.qty}</span>
3147
+ <span>\${item.price * item.qty}</span>
3148
+ </div>
3149
+ ))}
3150
+ <div style={{ borderTop: '1px solid #7c2d12', marginTop: '0.5rem', paddingTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700, color: '#fed7aa', fontSize: '0.85rem' }}>
3151
+ <span>Total</span><span>\${total}</span>
3152
+ </div>
3153
+
3154
+ <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem', flexWrap: 'wrap' }}>
3155
+ <button
3156
+ onClick={simulateCanaryError}
3157
+ style={{ padding: '0.3rem 0.75rem', background: '#7c2d12', border: '1px solid #9a3412', borderRadius: '0.4rem', color: '#fed7aa', fontSize: '0.75rem', cursor: 'pointer' }}
3158
+ >
3159
+ Submit order {deploymentVersion === '2.0.0' ? '(20% fail chance)' : '(always OK)'}
3160
+ </button>
3161
+ <button
3162
+ onClick={simulateSlowRender}
3163
+ style={{ padding: '0.3rem 0.75rem', background: '#451a03', border: '1px solid #7c2d12', borderRadius: '0.4rem', color: '#fb923c', fontSize: '0.75rem', cursor: 'pointer' }}
3164
+ >
3165
+ Simulate slow render ({'>'}{BUDGET_MS}ms)
3166
+ </button>
3167
+ </div>
3168
+ {slowResult && (
3169
+ <p style={{ margin: '0.5rem 0 0', color: '#ef4444', fontSize: '0.7rem' }}>⚠ {slowResult}</p>
3170
+ )}
3171
+ <p style={{ margin: '0.75rem 0 0', color: '#7c2d12', fontSize: '0.7rem', lineHeight: 1.4 }}>
3172
+ Switch host to canary v2.0.0 → submit orders → spot ERROR spans in the dashboard
3173
+ → this is how you'd auto-rollback a bad canary deploy.
3174
+ </p>
3175
+ </div>
3176
+ );
3177
+ }
3178
+ `;
3179
+
3180
+ const README = `# MFE + OpenTelemetry: Distributed Tracing & Performance Budgets
3181
+
3182
+ ## What this lab shows
3183
+
3184
+ | Concept | Where to look |
3185
+ |---------|---------------|
3186
+ | Tracer singleton shared across MFEs | \`apps/host/src/telemetry.js\` |
3187
+ | Span lifecycle (start → attributes → events → end) | all \`*.jsx\` files |
3188
+ | W3C traceparent header propagation | App.jsx "Make API call" button |
3189
+ | Performance budget enforcement | \`apps/checkout/src/CheckoutPanel.jsx\` |
3190
+ | Canary vs stable telemetry split | version selector in App.jsx |
3191
+ | OTLP-like span collector | host \`webpack.config.js\` setupMiddlewares |
3192
+ | Live trace waterfall UI | \`apps/host/src/TraceDashboard.jsx\` |
3193
+
3194
+ ## Canary deploy workflow
3195
+
3196
+ 1. Start the lab (\`npm run dev\` at root)
3197
+ 2. Load both remotes in the browser
3198
+ 3. Submit a few orders with **stable v1** — all green
3199
+ 4. Switch to **canary v2** — 20% of submit spans become ERROR
3200
+ 5. In a real system: an alert fires when the error-rate delta exceeds a threshold
3201
+ (\`error_rate{version="2.0.0"} > 2 * error_rate{version="1.0.0"}\`)
3202
+ 6. Auto-rollback restores v1 and the dashboard turns green
3203
+
3204
+ ## Experiments
3205
+
3206
+ 1. **Lower the budget**: change \`BUDGET_MS = 50\` to \`BUDGET_MS = 10\` in CheckoutPanel.jsx
3207
+ — every render exceeds budget → dashboard lights up red
3208
+ 2. **Add an attribute**: in ProfileCard.jsx add \`span.setAttribute('user.id', 'u_42')\`
3209
+ — click the span in the dashboard to see it
3210
+ 3. **Trace an async operation**: in App.jsx start a span before a \`setTimeout()\`,
3211
+ end it inside — observe the duration in the waterfall
3212
+ 4. **Export**: click "Export JSON" in the dashboard → open in VS Code → paste into
3213
+ https://jaeger.io (File > Upload) for a real Jaeger waterfall view
3214
+ `;
3215
+
3216
+ return {
3217
+ "README.md": README,
3218
+ "package.json": ROOT_PACKAGE,
3219
+ "apps/host/package.json": HOST_PACKAGE,
3220
+ "apps/host/webpack.config.js": HOST_WEBPACK,
3221
+ "apps/host/public/index.html": HOST_HTML,
3222
+ "apps/host/src/index.js": HOST_INDEX_JS,
3223
+ "apps/host/src/bootstrap.jsx": HOST_BOOTSTRAP,
3224
+ "apps/host/src/telemetry.js": HOST_TELEMETRY,
3225
+ "apps/host/src/App.jsx": HOST_APP,
3226
+ "apps/host/src/TraceDashboard.jsx": HOST_TRACE_DASHBOARD,
3227
+ "apps/profile/package.json": PROFILE_PACKAGE,
3228
+ "apps/profile/webpack.config.js": PROFILE_WEBPACK,
3229
+ "apps/profile/public/index.html": REMOTE_HTML("Profile Remote"),
3230
+ "apps/profile/src/index.js": REMOTE_INDEX,
3231
+ "apps/profile/src/bootstrap.js": REMOTE_BOOTSTRAP("ProfileCard"),
3232
+ "apps/profile/src/ProfileCard.jsx": PROFILE_CARD,
3233
+ "apps/checkout/package.json": CHECKOUT_PACKAGE,
3234
+ "apps/checkout/webpack.config.js": CHECKOUT_WEBPACK,
3235
+ "apps/checkout/public/index.html": REMOTE_HTML("Checkout Remote"),
3236
+ "apps/checkout/src/index.js": REMOTE_INDEX,
3237
+ "apps/checkout/src/bootstrap.js": REMOTE_BOOTSTRAP("CheckoutPanel"),
3238
+ "apps/checkout/src/CheckoutPanel.jsx": CHECKOUT_PANEL,
3239
+ };
3240
+ })(),
3241
+ },
3242
+ ];