create-interview-cockpit 0.6.0 → 0.8.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.
- package/package.json +1 -1
- package/template/client/src/api.ts +160 -2
- package/template/client/src/components/CodeContextPanel.tsx +111 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1000 -226
- package/template/client/src/reactLab.ts +518 -5
- package/template/client/src/store.ts +35 -4
- package/template/client/src/types.ts +3 -2
- package/template/client/tsconfig.tsbuildinfo +30 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +2 -0
- package/template/server/src/index.ts +642 -5
- package/template/server/src/storage.ts +11 -2
package/template/cockpit.json
CHANGED
|
@@ -360,6 +360,7 @@ export async function syncWorkspace(
|
|
|
360
360
|
cs.origin === "sandbox" ||
|
|
361
361
|
cs.origin === "react" ||
|
|
362
362
|
cs.origin === "nextjs" ||
|
|
363
|
+
cs.origin === "module-federation" ||
|
|
363
364
|
cs.origin === "infra")
|
|
364
365
|
) {
|
|
365
366
|
try {
|
|
@@ -770,6 +771,7 @@ export async function exportWorkspace(
|
|
|
770
771
|
cf.origin === "sandbox" ||
|
|
771
772
|
cf.origin === "react" ||
|
|
772
773
|
cf.origin === "nextjs" ||
|
|
774
|
+
cf.origin === "module-federation" ||
|
|
773
775
|
cf.origin === "infra"
|
|
774
776
|
) {
|
|
775
777
|
try {
|
|
@@ -690,7 +690,14 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
690
690
|
code: string;
|
|
691
691
|
language: string;
|
|
692
692
|
label: string;
|
|
693
|
-
origin:
|
|
693
|
+
origin:
|
|
694
|
+
| "user"
|
|
695
|
+
| "ai"
|
|
696
|
+
| "sandbox"
|
|
697
|
+
| "infra"
|
|
698
|
+
| "react"
|
|
699
|
+
| "nextjs"
|
|
700
|
+
| "module-federation";
|
|
694
701
|
};
|
|
695
702
|
if (typeof code !== "string" || !code.trim()) {
|
|
696
703
|
return res.status(400).json({ error: "code is required" });
|
|
@@ -701,11 +708,12 @@ app.post("/api/questions/:questionId/save-code-snippet", async (req, res) => {
|
|
|
701
708
|
origin !== "sandbox" &&
|
|
702
709
|
origin !== "infra" &&
|
|
703
710
|
origin !== "react" &&
|
|
704
|
-
origin !== "nextjs"
|
|
711
|
+
origin !== "nextjs" &&
|
|
712
|
+
origin !== "module-federation"
|
|
705
713
|
) {
|
|
706
714
|
return res.status(400).json({
|
|
707
715
|
error:
|
|
708
|
-
"origin must be 'user', 'ai', 'sandbox', 'infra', 'react', or '
|
|
716
|
+
"origin must be 'user', 'ai', 'sandbox', 'infra', 'react', 'nextjs', or 'module-federation'",
|
|
709
717
|
});
|
|
710
718
|
}
|
|
711
719
|
try {
|
|
@@ -994,7 +1002,7 @@ app.post("/api/frontend-lab/ask", async (req, res) => {
|
|
|
994
1002
|
const { messages, workspace, labType, questionId } = req.body as {
|
|
995
1003
|
messages?: Array<{ role: "user" | "assistant"; content: string }>;
|
|
996
1004
|
workspace?: Record<string, string>;
|
|
997
|
-
labType?: "react" | "nextjs";
|
|
1005
|
+
labType?: "react" | "nextjs" | "module-federation";
|
|
998
1006
|
questionId?: string;
|
|
999
1007
|
};
|
|
1000
1008
|
|
|
@@ -1008,7 +1016,11 @@ app.post("/api/frontend-lab/ask", async (req, res) => {
|
|
|
1008
1016
|
const aiSettings = await storage.getAiSettings();
|
|
1009
1017
|
|
|
1010
1018
|
const typeLabel =
|
|
1011
|
-
labType === "nextjs"
|
|
1019
|
+
labType === "nextjs"
|
|
1020
|
+
? "Next.js App Router"
|
|
1021
|
+
: labType === "module-federation"
|
|
1022
|
+
? "Webpack Module Federation"
|
|
1023
|
+
: "React + TypeScript";
|
|
1012
1024
|
|
|
1013
1025
|
let workspaceBlock = "";
|
|
1014
1026
|
if (workspace && typeof workspace === "object") {
|
|
@@ -2280,6 +2292,346 @@ const NEXT_MODULES_DIR = path.join(NEXT_NPX_DIR, "node_modules");
|
|
|
2280
2292
|
// walking up the directory tree — no symlinks needed, no Turbopack restrictions.
|
|
2281
2293
|
const NEXT_SANDBOX_BASE = path.join(NEXT_NPX_DIR, ".sandboxes");
|
|
2282
2294
|
|
|
2295
|
+
interface ModuleFederationSandboxEntry {
|
|
2296
|
+
child: ReturnType<typeof spawn>;
|
|
2297
|
+
dir: string;
|
|
2298
|
+
hostUrl: string;
|
|
2299
|
+
appUrls: Record<string, string>;
|
|
2300
|
+
workspaceFiles: Set<string>;
|
|
2301
|
+
logs: string[];
|
|
2302
|
+
ready: boolean;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
const moduleFederationSandboxes = new Map<
|
|
2306
|
+
string,
|
|
2307
|
+
ModuleFederationSandboxEntry
|
|
2308
|
+
>();
|
|
2309
|
+
const MODULE_FEDERATION_SANDBOX_BASE = path.join(
|
|
2310
|
+
os.tmpdir(),
|
|
2311
|
+
"interview-cockpit-module-federation",
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2314
|
+
function appendSandboxLog(logs: string[], text: string): string {
|
|
2315
|
+
const clean = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2316
|
+
logs.push(clean);
|
|
2317
|
+
if (logs.length > 400) {
|
|
2318
|
+
logs.splice(0, logs.length - 400);
|
|
2319
|
+
}
|
|
2320
|
+
return clean;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function npmCommand(): string {
|
|
2324
|
+
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
async function writeModuleFederationSandboxFiles(
|
|
2328
|
+
dir: string,
|
|
2329
|
+
files: Record<string, string>,
|
|
2330
|
+
) {
|
|
2331
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
2332
|
+
const fullPath = path.join(dir, filePath);
|
|
2333
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
2334
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async function runLoggedCommand(
|
|
2339
|
+
command: string,
|
|
2340
|
+
args: string[],
|
|
2341
|
+
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
|
2342
|
+
logs: string[],
|
|
2343
|
+
): Promise<void> {
|
|
2344
|
+
await new Promise<void>((resolve, reject) => {
|
|
2345
|
+
const child = spawn(command, args, {
|
|
2346
|
+
cwd: options.cwd,
|
|
2347
|
+
env: options.env,
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2351
|
+
appendSandboxLog(logs, chunk.toString());
|
|
2352
|
+
});
|
|
2353
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2354
|
+
appendSandboxLog(logs, chunk.toString());
|
|
2355
|
+
});
|
|
2356
|
+
child.on("error", reject);
|
|
2357
|
+
child.on("exit", (code) => {
|
|
2358
|
+
if (code === 0) {
|
|
2359
|
+
resolve();
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
reject(
|
|
2363
|
+
new Error(
|
|
2364
|
+
logs.join("").trim() ||
|
|
2365
|
+
`${command} ${args.join(" ")} exited with code ${String(code)}`,
|
|
2366
|
+
),
|
|
2367
|
+
);
|
|
2368
|
+
});
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
type ModuleFederationCommandOutputKind = "stdout" | "stderr" | "info";
|
|
2373
|
+
|
|
2374
|
+
const MODULE_FEDERATION_SHELL_META_TOKENS = new Set([
|
|
2375
|
+
"&&",
|
|
2376
|
+
"||",
|
|
2377
|
+
";",
|
|
2378
|
+
"|",
|
|
2379
|
+
">",
|
|
2380
|
+
">>",
|
|
2381
|
+
"<",
|
|
2382
|
+
"2>",
|
|
2383
|
+
"&",
|
|
2384
|
+
]);
|
|
2385
|
+
|
|
2386
|
+
function splitShellLikeCommand(command: string): string[] {
|
|
2387
|
+
const tokens: string[] = [];
|
|
2388
|
+
let current = "";
|
|
2389
|
+
let quote: '"' | "'" | null = null;
|
|
2390
|
+
let escape = false;
|
|
2391
|
+
|
|
2392
|
+
for (const char of command.trim()) {
|
|
2393
|
+
if (escape) {
|
|
2394
|
+
current += char;
|
|
2395
|
+
escape = false;
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
if (char === "\\") {
|
|
2400
|
+
escape = true;
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if (quote) {
|
|
2405
|
+
if (char === quote) {
|
|
2406
|
+
quote = null;
|
|
2407
|
+
} else {
|
|
2408
|
+
current += char;
|
|
2409
|
+
}
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
if (char === '"' || char === "'") {
|
|
2414
|
+
quote = char;
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
if (/\s/.test(char)) {
|
|
2419
|
+
if (current) {
|
|
2420
|
+
tokens.push(current);
|
|
2421
|
+
current = "";
|
|
2422
|
+
}
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
current += char;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
if (escape) current += "\\";
|
|
2430
|
+
if (quote) throw new Error("Command has an unclosed quote");
|
|
2431
|
+
if (current) tokens.push(current);
|
|
2432
|
+
return tokens;
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function normalizeSandboxRelativePath(filePath: string, label: string): string {
|
|
2436
|
+
const normalized = filePath.replace(/\\/g, "/").trim();
|
|
2437
|
+
if (!normalized || normalized === ".") {
|
|
2438
|
+
return "";
|
|
2439
|
+
}
|
|
2440
|
+
if (path.isAbsolute(normalized)) {
|
|
2441
|
+
throw new Error(`${label} must be relative to the webpack lab root`);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
2445
|
+
if (parts.some((part) => part === "." || part === "..")) {
|
|
2446
|
+
throw new Error(`${label} must stay inside the webpack lab root`);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
return parts.join("/");
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function isPathInside(root: string, target: string): boolean {
|
|
2453
|
+
const relative = path.relative(root, target);
|
|
2454
|
+
return (
|
|
2455
|
+
relative === "" ||
|
|
2456
|
+
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function parseModuleFederationCommand(command: string): {
|
|
2461
|
+
args: string[];
|
|
2462
|
+
displayCommand: string;
|
|
2463
|
+
} {
|
|
2464
|
+
const tokens = splitShellLikeCommand(command);
|
|
2465
|
+
if (tokens.length < 3 || tokens[0] !== "npm" || tokens[1] !== "run") {
|
|
2466
|
+
throw new Error(
|
|
2467
|
+
"Only npm run <script> commands are allowed in the webpack lab console",
|
|
2468
|
+
);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
if (tokens.some((token) => MODULE_FEDERATION_SHELL_META_TOKENS.has(token))) {
|
|
2472
|
+
throw new Error(
|
|
2473
|
+
"Shell operators are not supported here. Run one npm command at a time.",
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
if (
|
|
2478
|
+
tokens.some(
|
|
2479
|
+
(token) => token === "--prefix" || token.startsWith("--prefix="),
|
|
2480
|
+
)
|
|
2481
|
+
) {
|
|
2482
|
+
throw new Error(
|
|
2483
|
+
"Use the folder selector instead of npm --prefix in the webpack lab console.",
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
return {
|
|
2488
|
+
args: tokens.slice(1),
|
|
2489
|
+
displayCommand: `$ ${tokens.join(" ")}`,
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
async function resolveModuleFederationCommandCwd(
|
|
2494
|
+
sandboxDir: string,
|
|
2495
|
+
cwd: string | undefined,
|
|
2496
|
+
): Promise<{ normalized: string; fullPath: string }> {
|
|
2497
|
+
const normalized = normalizeSandboxRelativePath(
|
|
2498
|
+
cwd ?? "",
|
|
2499
|
+
"Command directory",
|
|
2500
|
+
);
|
|
2501
|
+
const fullPath = path.resolve(sandboxDir, normalized || ".");
|
|
2502
|
+
|
|
2503
|
+
if (!isPathInside(path.resolve(sandboxDir), fullPath)) {
|
|
2504
|
+
throw new Error(
|
|
2505
|
+
"Command directory must stay inside the webpack lab sandbox",
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
const stats = await fs.stat(fullPath).catch(() => null);
|
|
2510
|
+
if (!stats?.isDirectory()) {
|
|
2511
|
+
throw new Error(`Command directory not found: ${normalized || "."}`);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
return { normalized, fullPath };
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
function getModuleFederationCommandEnv(
|
|
2518
|
+
sandbox: ModuleFederationSandboxEntry,
|
|
2519
|
+
): NodeJS.ProcessEnv {
|
|
2520
|
+
const hostPort = new URL(sandbox.appUrls.host).port;
|
|
2521
|
+
const profilePort = new URL(sandbox.appUrls.profile).port;
|
|
2522
|
+
const checkoutPort = new URL(sandbox.appUrls.checkout).port;
|
|
2523
|
+
|
|
2524
|
+
return {
|
|
2525
|
+
...process.env,
|
|
2526
|
+
HOST_PORT: hostPort,
|
|
2527
|
+
PROFILE_PORT: profilePort,
|
|
2528
|
+
CHECKOUT_PORT: checkoutPort,
|
|
2529
|
+
npm_config_update_notifier: "false",
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
async function runStreamedCommand(
|
|
2534
|
+
command: string,
|
|
2535
|
+
args: string[],
|
|
2536
|
+
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
|
2537
|
+
onChunk: (payload: {
|
|
2538
|
+
kind: ModuleFederationCommandOutputKind;
|
|
2539
|
+
text: string;
|
|
2540
|
+
}) => void,
|
|
2541
|
+
): Promise<void> {
|
|
2542
|
+
await new Promise<void>((resolve, reject) => {
|
|
2543
|
+
const stdout: string[] = [];
|
|
2544
|
+
const stderr: string[] = [];
|
|
2545
|
+
const child = spawn(command, args, {
|
|
2546
|
+
cwd: options.cwd,
|
|
2547
|
+
env: options.env,
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2551
|
+
const text = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2552
|
+
stdout.push(text);
|
|
2553
|
+
onChunk({ kind: "stdout", text });
|
|
2554
|
+
});
|
|
2555
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2556
|
+
const text = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
2557
|
+
stderr.push(text);
|
|
2558
|
+
onChunk({ kind: "stderr", text });
|
|
2559
|
+
});
|
|
2560
|
+
child.on("error", (error) => {
|
|
2561
|
+
reject(new Error(`${command} launch failed: ${error.message}`));
|
|
2562
|
+
});
|
|
2563
|
+
child.on("close", (code) => {
|
|
2564
|
+
if (code === 0) {
|
|
2565
|
+
resolve();
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
reject(
|
|
2570
|
+
new Error(
|
|
2571
|
+
stderr.join("").trim() ||
|
|
2572
|
+
stdout.join("").trim() ||
|
|
2573
|
+
`${command} ${args.join(" ")} exited with code ${String(code)}`,
|
|
2574
|
+
),
|
|
2575
|
+
);
|
|
2576
|
+
});
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
async function walkAllFiles(dir: string, prefix = ""): Promise<string[]> {
|
|
2581
|
+
const entries = await fs
|
|
2582
|
+
.readdir(dir, { withFileTypes: true })
|
|
2583
|
+
.catch(() => []);
|
|
2584
|
+
const files: string[] = [];
|
|
2585
|
+
|
|
2586
|
+
for (const entry of entries) {
|
|
2587
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
2588
|
+
if (entry.isDirectory()) {
|
|
2589
|
+
files.push(...(await walkAllFiles(path.join(dir, entry.name), rel)));
|
|
2590
|
+
} else {
|
|
2591
|
+
files.push(rel);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
return files;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
async function listModuleFederationGeneratedFiles(
|
|
2599
|
+
dir: string,
|
|
2600
|
+
): Promise<string[]> {
|
|
2601
|
+
const files: string[] = [];
|
|
2602
|
+
|
|
2603
|
+
const visit = async (currentDir: string, prefix = "") => {
|
|
2604
|
+
const entries = await fs
|
|
2605
|
+
.readdir(currentDir, { withFileTypes: true })
|
|
2606
|
+
.catch(() => []);
|
|
2607
|
+
|
|
2608
|
+
for (const entry of entries) {
|
|
2609
|
+
if (entry.name === "node_modules") continue;
|
|
2610
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
2611
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
2612
|
+
|
|
2613
|
+
if (entry.isDirectory()) {
|
|
2614
|
+
if (entry.name === "dist") {
|
|
2615
|
+
files.push(...(await walkAllFiles(fullPath, rel)));
|
|
2616
|
+
} else {
|
|
2617
|
+
await visit(fullPath, rel);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
};
|
|
2622
|
+
|
|
2623
|
+
await visit(dir);
|
|
2624
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
async function getDistinctPorts(count: number): Promise<number[]> {
|
|
2628
|
+
const ports = new Set<number>();
|
|
2629
|
+
while (ports.size < count) {
|
|
2630
|
+
ports.add(await getFreePort());
|
|
2631
|
+
}
|
|
2632
|
+
return Array.from(ports);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2283
2635
|
/** Write all user files and necessary config into a sandbox directory. */
|
|
2284
2636
|
async function writeNextSandboxFiles(
|
|
2285
2637
|
dir: string,
|
|
@@ -2480,6 +2832,291 @@ app.delete("/api/nextjs/:id", async (req, res) => {
|
|
|
2480
2832
|
res.json({ ok: true });
|
|
2481
2833
|
});
|
|
2482
2834
|
|
|
2835
|
+
app.post("/api/module-federation/start", async (req, res) => {
|
|
2836
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
2837
|
+
if (!files || typeof files !== "object") {
|
|
2838
|
+
return res.status(400).json({ error: "files is required" });
|
|
2839
|
+
}
|
|
2840
|
+
if (typeof files["package.json"] !== "string") {
|
|
2841
|
+
return res.status(400).json({ error: "package.json is required" });
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
const id = randomUUID();
|
|
2845
|
+
const dir = path.join(MODULE_FEDERATION_SANDBOX_BASE, id);
|
|
2846
|
+
const logs: string[] = [];
|
|
2847
|
+
|
|
2848
|
+
try {
|
|
2849
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2850
|
+
await writeModuleFederationSandboxFiles(dir, files);
|
|
2851
|
+
|
|
2852
|
+
await runLoggedCommand(
|
|
2853
|
+
npmCommand(),
|
|
2854
|
+
["install", "--no-audit", "--no-fund", "--prefer-offline"],
|
|
2855
|
+
{
|
|
2856
|
+
cwd: dir,
|
|
2857
|
+
env: {
|
|
2858
|
+
...process.env,
|
|
2859
|
+
npm_config_update_notifier: "false",
|
|
2860
|
+
},
|
|
2861
|
+
},
|
|
2862
|
+
logs,
|
|
2863
|
+
);
|
|
2864
|
+
|
|
2865
|
+
const [hostPort, profilePort, checkoutPort] = await getDistinctPorts(3);
|
|
2866
|
+
const appUrls = {
|
|
2867
|
+
host: `http://localhost:${hostPort}`,
|
|
2868
|
+
profile: `http://localhost:${profilePort}`,
|
|
2869
|
+
checkout: `http://localhost:${checkoutPort}`,
|
|
2870
|
+
};
|
|
2871
|
+
const readyPorts = new Set<string>();
|
|
2872
|
+
|
|
2873
|
+
const child = spawn(npmCommand(), ["run", "dev"], {
|
|
2874
|
+
cwd: dir,
|
|
2875
|
+
env: {
|
|
2876
|
+
...process.env,
|
|
2877
|
+
HOST_PORT: String(hostPort),
|
|
2878
|
+
PROFILE_PORT: String(profilePort),
|
|
2879
|
+
CHECKOUT_PORT: String(checkoutPort),
|
|
2880
|
+
npm_config_update_notifier: "false",
|
|
2881
|
+
},
|
|
2882
|
+
});
|
|
2883
|
+
|
|
2884
|
+
const entry: ModuleFederationSandboxEntry = {
|
|
2885
|
+
child,
|
|
2886
|
+
dir,
|
|
2887
|
+
hostUrl: appUrls.host,
|
|
2888
|
+
appUrls,
|
|
2889
|
+
workspaceFiles: new Set(Object.keys(files)),
|
|
2890
|
+
logs,
|
|
2891
|
+
ready: false,
|
|
2892
|
+
};
|
|
2893
|
+
|
|
2894
|
+
const markReady = (text: string) => {
|
|
2895
|
+
if (text.includes(`localhost:${hostPort}`)) readyPorts.add("host");
|
|
2896
|
+
if (text.includes(`localhost:${profilePort}`)) readyPorts.add("profile");
|
|
2897
|
+
if (text.includes(`localhost:${checkoutPort}`))
|
|
2898
|
+
readyPorts.add("checkout");
|
|
2899
|
+
if (readyPorts.size === 3) {
|
|
2900
|
+
entry.ready = true;
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
|
|
2904
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
2905
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
2906
|
+
});
|
|
2907
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
2908
|
+
markReady(appendSandboxLog(logs, chunk.toString()));
|
|
2909
|
+
});
|
|
2910
|
+
child.on("exit", () => {
|
|
2911
|
+
moduleFederationSandboxes.delete(id);
|
|
2912
|
+
fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2915
|
+
moduleFederationSandboxes.set(id, entry);
|
|
2916
|
+
|
|
2917
|
+
const deadline = Date.now() + 90_000;
|
|
2918
|
+
while (!entry.ready && Date.now() < deadline) {
|
|
2919
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2920
|
+
if (!moduleFederationSandboxes.has(id)) {
|
|
2921
|
+
return res.status(500).json({
|
|
2922
|
+
error: logs.join("").trim() || "Webpack module federation lab exited",
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
if (!entry.ready) {
|
|
2928
|
+
return res.status(504).json({
|
|
2929
|
+
error: "Webpack module federation lab did not start in time",
|
|
2930
|
+
logs,
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
res.json({
|
|
2935
|
+
id,
|
|
2936
|
+
hostUrl: entry.hostUrl,
|
|
2937
|
+
appUrls: entry.appUrls,
|
|
2938
|
+
});
|
|
2939
|
+
} catch (error: any) {
|
|
2940
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
2941
|
+
res.status(500).json({
|
|
2942
|
+
error:
|
|
2943
|
+
logs.join("").trim() ||
|
|
2944
|
+
error?.message ||
|
|
2945
|
+
"Failed to start webpack module federation lab",
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
});
|
|
2949
|
+
|
|
2950
|
+
app.post("/api/module-federation/:id/update-files", async (req, res) => {
|
|
2951
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2952
|
+
if (!sandbox) return res.status(404).json({ error: "Sandbox not found" });
|
|
2953
|
+
|
|
2954
|
+
const { files } = req.body as { files?: Record<string, string> };
|
|
2955
|
+
if (!files || typeof files !== "object") {
|
|
2956
|
+
return res.status(400).json({ error: "files is required" });
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
const nextFiles = new Set(Object.keys(files));
|
|
2960
|
+
await Promise.all(
|
|
2961
|
+
Array.from(sandbox.workspaceFiles)
|
|
2962
|
+
.filter((filePath) => !nextFiles.has(filePath))
|
|
2963
|
+
.map((filePath) =>
|
|
2964
|
+
fs
|
|
2965
|
+
.rm(path.join(sandbox.dir, filePath), { force: true })
|
|
2966
|
+
.catch(() => {}),
|
|
2967
|
+
),
|
|
2968
|
+
);
|
|
2969
|
+
await writeModuleFederationSandboxFiles(sandbox.dir, files);
|
|
2970
|
+
sandbox.workspaceFiles = nextFiles;
|
|
2971
|
+
res.json({ ok: true });
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
app.get("/api/module-federation/:id/status", (req, res) => {
|
|
2975
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2976
|
+
if (!sandbox) {
|
|
2977
|
+
return res.json({ running: false });
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
res.setHeader("Cache-Control", "no-store");
|
|
2981
|
+
|
|
2982
|
+
res.json({
|
|
2983
|
+
running: true,
|
|
2984
|
+
ready: sandbox.ready,
|
|
2985
|
+
hostUrl: sandbox.hostUrl,
|
|
2986
|
+
appUrls: sandbox.appUrls,
|
|
2987
|
+
logs: sandbox.logs.slice(-80),
|
|
2988
|
+
});
|
|
2989
|
+
});
|
|
2990
|
+
|
|
2991
|
+
app.post("/api/module-federation/:id/command-stream", async (req, res) => {
|
|
2992
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
2993
|
+
|
|
2994
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
2995
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
2996
|
+
res.setHeader("Connection", "keep-alive");
|
|
2997
|
+
res.flushHeaders();
|
|
2998
|
+
|
|
2999
|
+
const send = (payload: unknown) => {
|
|
3000
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
3001
|
+
};
|
|
3002
|
+
|
|
3003
|
+
if (!sandbox) {
|
|
3004
|
+
send({ type: "error", error: "Sandbox not found" });
|
|
3005
|
+
res.end();
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
const { command, cwd } = req.body as { command?: string; cwd?: string };
|
|
3010
|
+
|
|
3011
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
3012
|
+
send({ type: "error", error: "command is required" });
|
|
3013
|
+
res.end();
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
try {
|
|
3018
|
+
const parsed = parseModuleFederationCommand(command);
|
|
3019
|
+
const resolvedCwd = await resolveModuleFederationCommandCwd(
|
|
3020
|
+
sandbox.dir,
|
|
3021
|
+
cwd,
|
|
3022
|
+
);
|
|
3023
|
+
|
|
3024
|
+
send({
|
|
3025
|
+
type: "output",
|
|
3026
|
+
kind: "info",
|
|
3027
|
+
text: `cwd: ${resolvedCwd.normalized || "."}\n${parsed.displayCommand}\n`,
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
await runStreamedCommand(
|
|
3031
|
+
npmCommand(),
|
|
3032
|
+
parsed.args,
|
|
3033
|
+
{
|
|
3034
|
+
cwd: resolvedCwd.fullPath,
|
|
3035
|
+
env: getModuleFederationCommandEnv(sandbox),
|
|
3036
|
+
},
|
|
3037
|
+
({ kind, text }) => {
|
|
3038
|
+
send({ type: "output", kind, text });
|
|
3039
|
+
},
|
|
3040
|
+
);
|
|
3041
|
+
|
|
3042
|
+
send({ type: "complete" });
|
|
3043
|
+
} catch (error: any) {
|
|
3044
|
+
send({
|
|
3045
|
+
type: "error",
|
|
3046
|
+
error: error?.message || "Failed to run webpack lab command",
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
res.end();
|
|
3051
|
+
});
|
|
3052
|
+
|
|
3053
|
+
app.get("/api/module-federation/:id/generated-files", async (req, res) => {
|
|
3054
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
3055
|
+
if (!sandbox) {
|
|
3056
|
+
return res.status(404).json({ error: "Sandbox not found" });
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
res.setHeader("Cache-Control", "no-store");
|
|
3060
|
+
|
|
3061
|
+
try {
|
|
3062
|
+
const files = await listModuleFederationGeneratedFiles(sandbox.dir);
|
|
3063
|
+
res.json({ files });
|
|
3064
|
+
} catch (error: any) {
|
|
3065
|
+
res.status(500).json({
|
|
3066
|
+
error: error?.message || "Failed to list webpack lab generated files",
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
app.get("/api/module-federation/:id/generated-file", async (req, res) => {
|
|
3072
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
3073
|
+
if (!sandbox) {
|
|
3074
|
+
return res.status(404).json({ error: "Sandbox not found" });
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
res.setHeader("Cache-Control", "no-store");
|
|
3078
|
+
|
|
3079
|
+
const filePath =
|
|
3080
|
+
typeof req.query.path === "string" ? req.query.path : undefined;
|
|
3081
|
+
if (!filePath) {
|
|
3082
|
+
return res.status(400).json({ error: "path is required" });
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
try {
|
|
3086
|
+
const normalized = normalizeSandboxRelativePath(
|
|
3087
|
+
filePath,
|
|
3088
|
+
"Generated file path",
|
|
3089
|
+
);
|
|
3090
|
+
if (!/(^|\/)dist\//.test(normalized)) {
|
|
3091
|
+
return res.status(400).json({
|
|
3092
|
+
error: "Only generated dist files can be read here",
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
const fullPath = path.resolve(sandbox.dir, normalized);
|
|
3097
|
+
if (!isPathInside(path.resolve(sandbox.dir), fullPath)) {
|
|
3098
|
+
return res.status(403).json({ error: "Access denied" });
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
const content = await fs.readFile(fullPath, "utf8");
|
|
3102
|
+
res.json({ path: normalized, content });
|
|
3103
|
+
} catch (error: any) {
|
|
3104
|
+
res.status(500).json({
|
|
3105
|
+
error: error?.message || "Failed to read webpack lab generated file",
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
});
|
|
3109
|
+
|
|
3110
|
+
app.delete("/api/module-federation/:id", async (req, res) => {
|
|
3111
|
+
const sandbox = moduleFederationSandboxes.get(req.params.id);
|
|
3112
|
+
if (sandbox) {
|
|
3113
|
+
sandbox.child.kill("SIGTERM");
|
|
3114
|
+
moduleFederationSandboxes.delete(req.params.id);
|
|
3115
|
+
await fs.rm(sandbox.dir, { recursive: true, force: true }).catch(() => {});
|
|
3116
|
+
}
|
|
3117
|
+
res.json({ ok: true });
|
|
3118
|
+
});
|
|
3119
|
+
|
|
2483
3120
|
async function getFreePort(): Promise<number> {
|
|
2484
3121
|
return new Promise((resolve, reject) => {
|
|
2485
3122
|
const srv = net.createServer();
|