🐛 fix: import button from ui root (#15599)

This commit is contained in:
YuTengjing
2026-06-10 14:19:04 +08:00
committed by GitHub
parent 7641cda958
commit a5f16c1184
4 changed files with 230 additions and 81 deletions
+143 -77
View File
@@ -1,34 +1,20 @@
import { type ChildProcess, spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import net from 'node:net';
const env = process.env.NODE_ENV || 'development';
const isWindows = process.platform === 'win32';
const shellEnv = Object.entries(process.env).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (typeof value === 'string') acc[key] = value;
return acc;
},
{},
);
const dotenvEnv: Record<string, string> = {};
const dotenvResult = dotenv.config({
override: true,
path: ['.env', `.env.${env}`, `.env.${env}.local`],
processEnv: dotenvEnv,
});
if (dotenvResult.parsed) {
const expanded = dotenvExpand.expand({
parsed: dotenvResult.parsed,
processEnv: { ...dotenvEnv, ...shellEnv },
});
Object.assign(process.env, expanded.parsed, shellEnv);
interface DevProcessHandle {
directPid?: number;
groupPid?: number;
isWindows: boolean;
}
const isWindows = process.platform === 'win32';
const NEXT_HOST = 'localhost';
/**
@@ -44,16 +30,19 @@ const resolveNextPort = (): number => {
return 3010;
};
const NEXT_PORT = resolveNextPort();
const NEXT_ROOT_URL = `http://${NEXT_HOST}:${NEXT_PORT}/`;
const NEXT_READY_TIMEOUT_MS = 180_000;
const NEXT_READY_RETRY_MS = 400;
const FORCE_KILL_TIMEOUT_MS = 5_000;
const npmCommand = isWindows ? 'npm.cmd' : 'npm';
let nextPort = 3010;
let nextRootUrl = `http://${NEXT_HOST}:${nextPort}/`;
let nextProcess: ChildProcess | undefined;
let viteProcess: ChildProcess | undefined;
let nextHandle: DevProcessHandle | undefined;
let viteHandle: DevProcessHandle | undefined;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
let shuttingDown = false;
const runNpmScript = (scriptName: string) =>
@@ -64,6 +53,66 @@ const runNpmScript = (scriptName: string) =>
shell: isWindows,
});
const loadEnv = () => {
const env = process.env.NODE_ENV || 'development';
const shellEnv = Object.entries(process.env).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (typeof value === 'string') acc[key] = value;
return acc;
},
{},
);
const dotenvEnv: Record<string, string> = {};
const dotenvResult = dotenv.config({
override: true,
path: ['.env', `.env.${env}`, `.env.${env}.local`],
processEnv: dotenvEnv,
});
if (!dotenvResult.parsed) return;
const expanded = dotenvExpand.expand({
parsed: dotenvResult.parsed,
processEnv: { ...dotenvEnv, ...shellEnv },
});
Object.assign(process.env, expanded.parsed, shellEnv);
};
const createDevProcessHandle = ({
isWindows,
pid,
}: {
isWindows: boolean;
pid?: number;
}): DevProcessHandle => ({
directPid: pid,
groupPid: isWindows ? undefined : pid,
isWindows,
});
const sendSignalToDevProcess = (handle: DevProcessHandle | undefined, signal: NodeJS.Signals) => {
if (!handle) return;
if (!handle.isWindows && handle.groupPid) {
try {
process.kill(-handle.groupPid, signal);
return;
} catch {
// Fall through to the direct child pid below. The wrapper may already be
// gone while its process group has been reaped.
}
}
if (!handle.directPid) return;
try {
process.kill(handle.directPid, signal);
} catch {
// The process already exited.
}
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const isPortOpen = (host: string, port: number) =>
@@ -84,25 +133,27 @@ const waitForNextReady = async () => {
const startedAt = Date.now();
while (Date.now() - startedAt < NEXT_READY_TIMEOUT_MS) {
if (await isPortOpen(NEXT_HOST, NEXT_PORT)) return;
if (await isPortOpen(NEXT_HOST, nextPort)) return;
await wait(NEXT_READY_RETRY_MS);
}
throw new Error(
`Next server was not ready within ${NEXT_READY_TIMEOUT_MS / 1000}s on ${NEXT_HOST}:${NEXT_PORT}`,
`Next server was not ready within ${NEXT_READY_TIMEOUT_MS / 1000}s on ${NEXT_HOST}:${nextPort}`,
);
};
const prewarmNextRootCompile = async () => {
const startedAt = Date.now();
const response = await fetch(NEXT_ROOT_URL, { signal: AbortSignal.timeout(120_000) });
const response = await fetch(nextRootUrl, { signal: AbortSignal.timeout(120_000) });
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(2);
console.log(`✅ Next prewarm request finished (${response.status}) in ${elapsed}s ${NEXT_ROOT_URL}`);
console.log(
`✅ Next prewarm request finished (${response.status}) in ${elapsed}s ${nextRootUrl}`,
);
};
const runNextBackgroundTasks = () => {
setTimeout(() => {
console.log(`🔁 Next server URL: ${NEXT_ROOT_URL}`);
console.log(`🔁 Next server URL: ${nextRootUrl}`);
}, 2_000);
void (async () => {
@@ -115,67 +166,69 @@ const runNextBackgroundTasks = () => {
})();
};
const isChildAlive = (child: ChildProcess) =>
!child.killed && child.exitCode === null && child.signalCode === null;
const sendKillSignal = (child: ChildProcess, signal: NodeJS.Signals) => {
if (!isChildAlive(child) || !child.pid) return;
try {
if (!isWindows) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// process group kill failed; fall through to direct kill
}
}
child.kill(signal);
} catch {
// child already gone
}
const terminateChildren = () => {
sendSignalToDevProcess(viteHandle, 'SIGTERM');
sendSignalToDevProcess(nextHandle, 'SIGTERM');
};
const terminateChild = (child?: ChildProcess) => {
if (!child) return;
sendKillSignal(child, 'SIGTERM');
const forceKillChildren = () => {
sendSignalToDevProcess(viteHandle, 'SIGKILL');
sendSignalToDevProcess(nextHandle, 'SIGKILL');
};
const forceKillChild = (child?: ChildProcess) => {
if (!child) return;
sendKillSignal(child, 'SIGKILL');
const clearForceKillTimer = () => {
if (!forceKillTimer) return;
clearTimeout(forceKillTimer);
forceKillTimer = undefined;
};
const hasChildSettled = (child?: ChildProcess) =>
!child || child.exitCode !== null || child.signalCode !== null;
const clearForceKillTimerWhenChildrenSettle = () => {
if (!shuttingDown) return;
if (hasChildSettled(nextProcess) && hasChildSettled(viteProcess)) clearForceKillTimer();
};
const shutdownAll = (signal: NodeJS.Signals) => {
if (shuttingDown) return;
if (shuttingDown) {
forceKillChildren();
return;
}
shuttingDown = true;
terminateChild(viteProcess);
terminateChild(nextProcess);
terminateChildren();
process.exitCode = signal === 'SIGINT' ? 130 : 143;
const forceKillTimer = setTimeout(() => {
forceKillChild(viteProcess);
forceKillChild(nextProcess);
forceKillTimer = setTimeout(() => {
forceKillTimer = undefined;
forceKillChildren();
}, FORCE_KILL_TIMEOUT_MS);
forceKillTimer.unref();
};
const watchChildExit = (child: ChildProcess, name: 'next' | 'vite') => {
child.once('exit', (code, signal) => {
if (!shuttingDown) {
console.error(
`${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`,
);
shutdownAll('SIGTERM');
if (shuttingDown) {
clearForceKillTimerWhenChildrenSettle();
return;
}
console.error(
`${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`,
);
shutdownAll('SIGTERM');
});
};
const main = async () => {
loadEnv();
nextPort = resolveNextPort();
nextRootUrl = `http://${NEXT_HOST}:${nextPort}/`;
const forwardedSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
for (const sig of forwardedSignals) {
process.once(sig, () => shutdownAll(sig));
process.on(sig, () => shutdownAll(sig));
}
process.on('uncaughtException', (error) => {
@@ -189,19 +242,20 @@ const main = async () => {
});
process.on('exit', () => {
forceKillChild(viteProcess);
forceKillChild(nextProcess);
forceKillChildren();
});
nextProcess = spawn('npx', ['next', 'dev', '-p', String(NEXT_PORT)], {
nextProcess = spawn('npx', ['next', 'dev', '-p', String(nextPort)], {
detached: !isWindows,
env: process.env,
stdio: 'inherit',
shell: isWindows,
});
nextHandle = createDevProcessHandle({ isWindows, pid: nextProcess.pid });
watchChildExit(nextProcess, 'next');
viteProcess = runNpmScript('dev:spa');
viteHandle = createDevProcessHandle({ isWindows, pid: viteProcess.pid });
watchChildExit(viteProcess, 'vite');
runNextBackgroundTasks();
@@ -211,7 +265,19 @@ const main = async () => {
]);
};
void main().catch((error) => {
console.error('❌ dev startup sequence failed:', error);
shutdownAll('SIGTERM');
});
const isMainModule = () => {
const entry = process.argv[1];
return !!entry && import.meta.url === pathToFileURL(path.resolve(entry)).href;
};
export const __testing = {
createDevProcessHandle,
sendSignalToDevProcess,
};
if (isMainModule()) {
void main().catch((error) => {
console.error('❌ dev startup sequence failed:', error);
shutdownAll('SIGTERM');
});
}
@@ -1,9 +1,8 @@
'use client';
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { Flexbox, Icon } from '@lobehub/ui';
import { Button, Flexbox, Icon } from '@lobehub/ui';
import {
Button,
createModal,
type ImperativeModalProps,
ModalFooter,
@@ -1,8 +1,8 @@
'use client';
import type { TaskTemplate } from '@lobechat/const';
import { ActionIcon, Flexbox, Icon, Markdown, Text } from '@lobehub/ui';
import { Button, createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui';
import { ActionIcon, Button, Flexbox, Icon, Markdown, Text } from '@lobehub/ui';
import { createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui';
import { Divider } from 'antd';
import { cssVar } from 'antd-style';
import { Clock, X } from 'lucide-react';
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
interface DevProcessHandle {
directPid?: number;
groupPid?: number;
isWindows: boolean;
}
interface DevStartupTestingExports {
__testing: {
createDevProcessHandle: (params: { isWindows: boolean; pid?: number }) => DevProcessHandle;
sendSignalToDevProcess: (handle: DevProcessHandle | undefined, signal: NodeJS.Signals) => void;
};
}
const loadTestingExports = async () => {
const modulePath = '../../scripts/devStartupSequence' + '.mts';
return (await import(modulePath)) as unknown as DevStartupTestingExports;
};
describe('devProcessCleanup', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should save the detached process group pid on Unix', async () => {
const { createDevProcessHandle } = (await loadTestingExports()).__testing;
expect(createDevProcessHandle({ isWindows: false, pid: 1234 })).toEqual({
directPid: 1234,
groupPid: 1234,
isWindows: false,
});
});
it('should signal the saved process group without requiring the direct child to be alive', async () => {
const { sendSignalToDevProcess } = (await loadTestingExports()).__testing;
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
sendSignalToDevProcess(
{
directPid: 1234,
groupPid: 1234,
isWindows: false,
},
'SIGTERM',
);
expect(kill).toHaveBeenCalledTimes(1);
expect(kill).toHaveBeenCalledWith(-1234, 'SIGTERM');
});
it('should fall back to the direct child pid when the process group is already gone', async () => {
const { sendSignalToDevProcess } = (await loadTestingExports()).__testing;
const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => {
if (pid < 0) throw new Error('missing process group');
return true;
});
sendSignalToDevProcess(
{
directPid: 1234,
groupPid: 1234,
isWindows: false,
},
'SIGKILL',
);
expect(kill).toHaveBeenCalledTimes(2);
expect(kill).toHaveBeenNthCalledWith(1, -1234, 'SIGKILL');
expect(kill).toHaveBeenNthCalledWith(2, 1234, 'SIGKILL');
});
it('should signal only the direct child pid on Windows', async () => {
const { createDevProcessHandle, sendSignalToDevProcess } = (await loadTestingExports())
.__testing;
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
sendSignalToDevProcess(createDevProcessHandle({ isWindows: true, pid: 1234 }), 'SIGTERM');
expect(kill).toHaveBeenCalledTimes(1);
expect(kill).toHaveBeenCalledWith(1234, 'SIGTERM');
});
});