2026-06-10 14:19:04 +08:00
|
|
|
import type { ChildProcess } from 'node:child_process';
|
|
|
|
|
import { spawn } from 'node:child_process';
|
2026-02-28 00:01:01 +08:00
|
|
|
import net from 'node:net';
|
2026-06-10 14:19:04 +08:00
|
|
|
import path from 'node:path';
|
|
|
|
|
import { pathToFileURL } from 'node:url';
|
2026-03-26 16:52:35 +08:00
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
import dotenv from 'dotenv';
|
|
|
|
|
import dotenvExpand from 'dotenv-expand';
|
2026-05-16 13:52:08 +08:00
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
interface DevProcessHandle {
|
|
|
|
|
directPid?: number;
|
|
|
|
|
groupPid?: number;
|
|
|
|
|
isWindows: boolean;
|
2026-05-16 13:52:08 +08:00
|
|
|
}
|
2026-02-28 00:01:01 +08:00
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
|
|
2026-02-28 00:01:01 +08:00
|
|
|
const NEXT_HOST = 'localhost';
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-26 16:52:35 +08:00
|
|
|
* Resolve the Next.js dev port.
|
2026-03-27 00:49:23 +08:00
|
|
|
* Priority: -p CLI flag > PORT env var > 3010.
|
2026-02-28 00:01:01 +08:00
|
|
|
*/
|
|
|
|
|
const resolveNextPort = (): number => {
|
2026-03-27 00:49:23 +08:00
|
|
|
const pIndex = process.argv.indexOf('-p');
|
|
|
|
|
if (pIndex !== -1 && process.argv[pIndex + 1]) {
|
|
|
|
|
return Number(process.argv[pIndex + 1]);
|
|
|
|
|
}
|
2026-03-26 16:52:35 +08:00
|
|
|
if (process.env.PORT) return Number(process.env.PORT);
|
2026-02-28 00:01:01 +08:00
|
|
|
return 3010;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const NEXT_READY_TIMEOUT_MS = 180_000;
|
|
|
|
|
const NEXT_READY_RETRY_MS = 400;
|
2026-05-29 13:55:14 +08:00
|
|
|
const FORCE_KILL_TIMEOUT_MS = 5_000;
|
2026-02-28 00:01:01 +08:00
|
|
|
|
2026-05-29 13:55:14 +08:00
|
|
|
const npmCommand = isWindows ? 'npm.cmd' : 'npm';
|
2026-02-28 00:01:01 +08:00
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
let nextPort = 3010;
|
|
|
|
|
let nextRootUrl = `http://${NEXT_HOST}:${nextPort}/`;
|
2026-02-28 00:01:01 +08:00
|
|
|
let nextProcess: ChildProcess | undefined;
|
|
|
|
|
let viteProcess: ChildProcess | undefined;
|
2026-06-10 14:19:04 +08:00
|
|
|
let nextHandle: DevProcessHandle | undefined;
|
|
|
|
|
let viteHandle: DevProcessHandle | undefined;
|
|
|
|
|
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
2026-02-28 00:01:01 +08:00
|
|
|
let shuttingDown = false;
|
|
|
|
|
|
|
|
|
|
const runNpmScript = (scriptName: string) =>
|
|
|
|
|
spawn(npmCommand, ['run', scriptName], {
|
2026-05-29 13:55:14 +08:00
|
|
|
detached: !isWindows,
|
2026-02-28 00:01:01 +08:00
|
|
|
env: process.env,
|
|
|
|
|
stdio: 'inherit',
|
2026-05-29 13:55:14 +08:00
|
|
|
shell: isWindows,
|
2026-02-28 00:01:01 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
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.
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-28 00:01:01 +08:00
|
|
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
|
|
|
|
|
const isPortOpen = (host: string, port: number) =>
|
|
|
|
|
new Promise<boolean>((resolve) => {
|
|
|
|
|
const socket = net.createConnection({ host, port });
|
|
|
|
|
const onDone = (result: boolean) => {
|
|
|
|
|
socket.removeAllListeners();
|
|
|
|
|
socket.destroy();
|
|
|
|
|
resolve(result);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.once('connect', () => onDone(true));
|
|
|
|
|
socket.once('error', () => onDone(false));
|
|
|
|
|
socket.setTimeout(1_000, () => onDone(false));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const waitForNextReady = async () => {
|
|
|
|
|
const startedAt = Date.now();
|
|
|
|
|
|
|
|
|
|
while (Date.now() - startedAt < NEXT_READY_TIMEOUT_MS) {
|
2026-06-10 14:19:04 +08:00
|
|
|
if (await isPortOpen(NEXT_HOST, nextPort)) return;
|
2026-02-28 00:01:01 +08:00
|
|
|
await wait(NEXT_READY_RETRY_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(
|
2026-06-10 14:19:04 +08:00
|
|
|
`Next server was not ready within ${NEXT_READY_TIMEOUT_MS / 1000}s on ${NEXT_HOST}:${nextPort}`,
|
2026-02-28 00:01:01 +08:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const prewarmNextRootCompile = async () => {
|
2026-04-22 19:59:38 +08:00
|
|
|
const startedAt = Date.now();
|
2026-06-10 14:19:04 +08:00
|
|
|
const response = await fetch(nextRootUrl, { signal: AbortSignal.timeout(120_000) });
|
2026-04-22 19:59:38 +08:00
|
|
|
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(2);
|
2026-06-10 14:19:04 +08:00
|
|
|
console.log(
|
|
|
|
|
`✅ Next prewarm request finished (${response.status}) in ${elapsed}s ${nextRootUrl}`,
|
|
|
|
|
);
|
2026-02-28 00:01:01 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runNextBackgroundTasks = () => {
|
|
|
|
|
setTimeout(() => {
|
2026-06-10 14:19:04 +08:00
|
|
|
console.log(`🔁 Next server URL: ${nextRootUrl}`);
|
2026-02-28 00:01:01 +08:00
|
|
|
}, 2_000);
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
await waitForNextReady();
|
|
|
|
|
await prewarmNextRootCompile();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('⚠️ Next prewarm skipped:', error);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
const terminateChildren = () => {
|
|
|
|
|
sendSignalToDevProcess(viteHandle, 'SIGTERM');
|
|
|
|
|
sendSignalToDevProcess(nextHandle, 'SIGTERM');
|
|
|
|
|
};
|
2026-05-29 13:55:14 +08:00
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
const forceKillChildren = () => {
|
|
|
|
|
sendSignalToDevProcess(viteHandle, 'SIGKILL');
|
|
|
|
|
sendSignalToDevProcess(nextHandle, 'SIGKILL');
|
2026-05-29 13:55:14 +08:00
|
|
|
};
|
|
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
const clearForceKillTimer = () => {
|
|
|
|
|
if (!forceKillTimer) return;
|
|
|
|
|
clearTimeout(forceKillTimer);
|
|
|
|
|
forceKillTimer = undefined;
|
2026-05-29 13:55:14 +08:00
|
|
|
};
|
|
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
const hasChildSettled = (child?: ChildProcess) =>
|
|
|
|
|
!child || child.exitCode !== null || child.signalCode !== null;
|
|
|
|
|
|
|
|
|
|
const clearForceKillTimerWhenChildrenSettle = () => {
|
|
|
|
|
if (!shuttingDown) return;
|
|
|
|
|
if (hasChildSettled(nextProcess) && hasChildSettled(viteProcess)) clearForceKillTimer();
|
2026-02-28 00:01:01 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const shutdownAll = (signal: NodeJS.Signals) => {
|
2026-06-10 14:19:04 +08:00
|
|
|
if (shuttingDown) {
|
|
|
|
|
forceKillChildren();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-28 00:01:01 +08:00
|
|
|
shuttingDown = true;
|
|
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
terminateChildren();
|
2026-02-28 00:01:01 +08:00
|
|
|
|
|
|
|
|
process.exitCode = signal === 'SIGINT' ? 130 : 143;
|
2026-05-29 13:55:14 +08:00
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
forceKillTimer = setTimeout(() => {
|
|
|
|
|
forceKillTimer = undefined;
|
|
|
|
|
forceKillChildren();
|
2026-05-29 13:55:14 +08:00
|
|
|
}, FORCE_KILL_TIMEOUT_MS);
|
2026-02-28 00:01:01 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const watchChildExit = (child: ChildProcess, name: 'next' | 'vite') => {
|
|
|
|
|
child.once('exit', (code, signal) => {
|
2026-06-10 14:19:04 +08:00
|
|
|
if (shuttingDown) {
|
|
|
|
|
clearForceKillTimerWhenChildrenSettle();
|
|
|
|
|
return;
|
2026-02-28 00:01:01 +08:00
|
|
|
}
|
2026-06-10 14:19:04 +08:00
|
|
|
|
|
|
|
|
console.error(
|
|
|
|
|
`❌ ${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`,
|
|
|
|
|
);
|
|
|
|
|
shutdownAll('SIGTERM');
|
2026-02-28 00:01:01 +08:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const main = async () => {
|
2026-06-10 14:19:04 +08:00
|
|
|
loadEnv();
|
|
|
|
|
nextPort = resolveNextPort();
|
|
|
|
|
nextRootUrl = `http://${NEXT_HOST}:${nextPort}/`;
|
|
|
|
|
|
2026-05-29 13:55:14 +08:00
|
|
|
const forwardedSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
|
|
|
for (const sig of forwardedSignals) {
|
2026-06-10 14:19:04 +08:00
|
|
|
process.on(sig, () => shutdownAll(sig));
|
2026-05-29 13:55:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
|
|
|
console.error('❌ uncaught exception in dev startup:', error);
|
|
|
|
|
shutdownAll('SIGTERM');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
|
|
|
console.error('❌ unhandled rejection in dev startup:', reason);
|
|
|
|
|
shutdownAll('SIGTERM');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
process.on('exit', () => {
|
2026-06-10 14:19:04 +08:00
|
|
|
forceKillChildren();
|
2026-05-29 13:55:14 +08:00
|
|
|
});
|
2026-02-28 00:01:01 +08:00
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
nextProcess = spawn('npx', ['next', 'dev', '-p', String(nextPort)], {
|
2026-05-29 13:55:14 +08:00
|
|
|
detached: !isWindows,
|
2026-03-26 16:52:35 +08:00
|
|
|
env: process.env,
|
|
|
|
|
stdio: 'inherit',
|
2026-05-29 13:55:14 +08:00
|
|
|
shell: isWindows,
|
2026-03-26 16:52:35 +08:00
|
|
|
});
|
2026-06-10 14:19:04 +08:00
|
|
|
nextHandle = createDevProcessHandle({ isWindows, pid: nextProcess.pid });
|
2026-02-28 00:01:01 +08:00
|
|
|
watchChildExit(nextProcess, 'next');
|
|
|
|
|
|
|
|
|
|
viteProcess = runNpmScript('dev:spa');
|
2026-06-10 14:19:04 +08:00
|
|
|
viteHandle = createDevProcessHandle({ isWindows, pid: viteProcess.pid });
|
2026-02-28 00:01:01 +08:00
|
|
|
watchChildExit(viteProcess, 'vite');
|
|
|
|
|
runNextBackgroundTasks();
|
|
|
|
|
|
|
|
|
|
await Promise.race([
|
|
|
|
|
new Promise((resolve) => nextProcess?.once('exit', resolve)),
|
|
|
|
|
new Promise((resolve) => viteProcess?.once('exit', resolve)),
|
|
|
|
|
]);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 14:19:04 +08:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
}
|