Files
xll-mini-program/vite-plugins/autoStartMakeServerPlugin.ts
王冕 a619938e0c Initial commit: 小羚羚小程序 Axhub Make workspace.
Include xll-miniapp prototype, PRD resources, annotation directory sync, agent skills, and cloud publishing setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 15:32:30 +08:00

499 lines
15 KiB
TypeScript

import type { Plugin } from 'vite';
import fs from 'node:fs';
import type { IncomingMessage, ServerResponse } from 'node:http';
import path from 'node:path';
import { spawn } from 'node:child_process';
import {
fetchHealth,
normalizeHealthServerInfo,
readServerInfo,
} from '../scripts/utils/serverInfo.mjs';
import {
PRODUCT_NAME,
PROJECT_ID,
PROJECT_NAME,
readMakeClientProjectIdentity,
} from '../scripts/sync-project-metadata.mjs';
import { MAKE_CONFIG_RELATIVE_PATH } from './utils/makeConstants';
const DEFAULT_ADMIN_ORIGIN = 'http://localhost:5174';
const STATUS_ROUTE = '/__axhub/make-server/status';
const START_ROUTE = '/__axhub/make-server/start';
const DEFAULT_ADMIN_HEALTH_TIMEOUT_MS = 1200;
const DEFAULT_ADMIN_READY_TIMEOUT_MS = 60000;
const DEFAULT_ADMIN_READY_POLL_INTERVAL_MS = 500;
const SKIP_AUTO_START_SERVER_ENV = 'AXHUB_MAKE_SKIP_AUTO_START_SERVER';
type RegisteredProject = {
projectId: string;
projectName: string;
};
type MakeServerStartCommand = {
command: string;
args: string[];
label: string;
};
type MakeServerStatusPayload = {
ready: boolean;
starting: boolean;
adminOrigin: string | null;
adminUrl: string | null;
projectId: string;
projectName: string;
error?: string;
};
let startPromise: Promise<MakeServerStatusPayload> | null = null;
type ReusableAdminOriginOptions = {
requireDevMode?: boolean;
runtimeOrigin?: string;
healthTimeoutMs?: number;
};
type MakeServerStartOptions = {
runtimeOrigin?: string;
adminReadyTimeoutMs?: number;
pollIntervalMs?: number;
healthTimeoutMs?: number;
};
type AdminOriginWaitOptions = ReusableAdminOriginOptions & {
timeoutMs?: number;
pollIntervalMs?: number;
};
function findWorkspaceRoot(projectRoot: string): string | null {
let current = path.resolve(projectRoot);
while (true) {
if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
function resolveLocalMakeServerCli(projectRoot: string): string | null {
const workspaceRoot = findWorkspaceRoot(projectRoot);
if (workspaceRoot) {
const workspaceCandidates = [
path.join(workspaceRoot, 'bin/cli.mjs'),
path.join(workspaceRoot, 'apps/make-server/bin/cli.mjs'),
];
const workspaceCli = workspaceCandidates.find((candidate) => fs.existsSync(candidate));
if (workspaceCli) {
return workspaceCli;
}
}
const installedCli = path.join(projectRoot, 'node_modules/@axhub/make/bin/cli.mjs');
return fs.existsSync(installedCli) ? installedCli : null;
}
/** Dev mode needs the make-server source tree; npm packages only ship dist/admin. */
function canRunMakeServerDevMode(projectRoot: string): boolean {
const workspaceRoot = findWorkspaceRoot(projectRoot);
if (workspaceRoot) {
const workspaceDevIndex = path.join(workspaceRoot, 'src/index/index.html');
if (fs.existsSync(workspaceDevIndex)) {
return true;
}
}
const installedDevIndex = path.join(
projectRoot,
'node_modules/@axhub/make/src/index/index.html',
);
return fs.existsSync(installedDevIndex);
}
function normalizeOrigin(origin: unknown): string | null {
if (typeof origin !== 'string') {
return null;
}
const trimmed = origin.trim().replace(/\/+$/u, '');
return trimmed || null;
}
export function resolveMakeServerStartCommand(
projectRoot: string,
options: MakeServerStartOptions = {},
): MakeServerStartCommand {
const runtimeOrigin = normalizeOrigin(options.runtimeOrigin);
const cliPath = resolveLocalMakeServerCli(projectRoot);
if (cliPath) {
const useDevMode = canRunMakeServerDevMode(projectRoot);
const args = [cliPath, projectRoot];
if (useDevMode) {
args.push('--dev');
}
if (runtimeOrigin) {
args.push('--runtime-origin', runtimeOrigin);
}
return {
command: process.execPath,
args,
label: useDevMode ? 'local @axhub/make dev' : 'local @axhub/make',
};
}
const args = ['@axhub/make@0.6.4', projectRoot];
if (runtimeOrigin) {
args.push('--runtime-origin', runtimeOrigin);
}
return {
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args,
label: 'npx @axhub/make',
};
}
export function createAdminUrl(adminOrigin: string, projectId?: string): string {
const url = new URL('/', adminOrigin);
const normalizedProjectId = projectId?.trim();
if (normalizedProjectId) {
url.searchParams.set('projectId', normalizedProjectId);
}
return url.toString();
}
export async function getReusableAdminOrigin(
projectRoot: string,
options: ReusableAdminOriginOptions = {},
): Promise<string | null> {
const info = readServerInfo(projectRoot, 'admin');
const candidates = [
info?.origin,
DEFAULT_ADMIN_ORIGIN,
].filter((origin, index, all): origin is string => Boolean(origin) && all.indexOf(origin) === index);
for (const origin of candidates) {
const health = await fetchHealth(origin, options.healthTimeoutMs ?? DEFAULT_ADMIN_HEALTH_TIMEOUT_MS);
if (normalizeHealthServerInfo(health)?.origin && isReusableAdminHealth(health, options)) {
return origin;
}
}
return null;
}
function isReusableAdminHealth(health: unknown, options: ReusableAdminOriginOptions): boolean {
if (!options.requireDevMode) {
return isReusableRuntimeOrigin(health, options);
}
if (!health || typeof health !== 'object') {
return false;
}
const payload = health as { role?: unknown; devMode?: unknown };
return payload.role === 'admin'
&& payload.devMode === true
&& isReusableRuntimeOrigin(health, options);
}
function isReusableRuntimeOrigin(health: unknown, options: ReusableAdminOriginOptions): boolean {
const expectedRuntimeOrigin = normalizeOrigin(options.runtimeOrigin);
if (!expectedRuntimeOrigin) {
return true;
}
if (!health || typeof health !== 'object') {
return false;
}
const payload = health as { runtimeOrigin?: unknown };
return normalizeOrigin(payload.runtimeOrigin) === expectedRuntimeOrigin;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function shouldRequireDevAdmin(projectRoot: string): boolean {
return canRunMakeServerDevMode(projectRoot) && Boolean(resolveLocalMakeServerCli(projectRoot));
}
export async function waitForAdminOrigin(
projectRoot: string,
options: AdminOriginWaitOptions = {},
): Promise<string | null> {
const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_ADMIN_READY_TIMEOUT_MS);
const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_ADMIN_READY_POLL_INTERVAL_MS);
const startedAt = Date.now();
while (Date.now() - startedAt <= timeoutMs) {
const origin = await getReusableAdminOrigin(projectRoot, options);
if (origin) {
return origin;
}
const elapsedMs = Date.now() - startedAt;
if (elapsedMs >= timeoutMs) {
break;
}
await sleep(Math.min(pollIntervalMs, timeoutMs - elapsedMs));
}
return null;
}
export async function registerOfficialProject(projectRoot: string, adminOrigin: string): Promise<RegisteredProject> {
const resolvedProjectRoot = path.resolve(projectRoot);
const metadataPath = path.join(resolvedProjectRoot, '.axhub/make/project.json');
const identity = readMakeClientProjectIdentity(resolvedProjectRoot);
const body = {
id: identity.id,
name: identity.name,
root: resolvedProjectRoot,
metadataPath,
};
const projectsResponse = await fetch(new URL('/api/projects', adminOrigin), {
headers: { accept: 'application/json' },
});
if (!projectsResponse.ok) {
throw new Error(`GET /api/projects failed with ${projectsResponse.status}`);
}
const registry = await projectsResponse.json() as any;
const existing = Array.isArray(registry.projects)
? registry.projects.find((project: any) => project.id === identity.id || path.resolve(project.root || '') === resolvedProjectRoot)
: null;
if (existing) {
const response = await fetch(new URL(`/api/projects/${encodeURIComponent(existing.id)}`, adminOrigin), {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: identity.name,
root: resolvedProjectRoot,
metadataPath,
}),
});
if (!response.ok) {
throw new Error(`PATCH /api/projects/${existing.id} failed with ${response.status}`);
}
} else {
const response = await fetch(new URL('/api/projects/make/register-existing', adminOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ root: resolvedProjectRoot }),
});
if (!response.ok && response.status !== 409) {
throw new Error(`POST /api/projects/make/register-existing failed with ${response.status}`);
}
}
return {
projectId: existing?.id || identity.id,
projectName: identity.name,
};
}
function createStatusPayload(adminOrigin: string | null, registration?: RegisteredProject): MakeServerStatusPayload {
const projectId = registration?.projectId || PROJECT_ID;
return {
ready: Boolean(adminOrigin),
starting: Boolean(startPromise),
adminOrigin,
adminUrl: adminOrigin ? createAdminUrl(adminOrigin, projectId) : null,
projectId,
projectName: registration?.projectName || PROJECT_NAME,
};
}
async function getRegisteredMakeServerStatus(
projectRoot: string,
options: MakeServerStartOptions = {},
): Promise<MakeServerStatusPayload> {
if (startPromise) {
return {
...createStatusPayload(null),
starting: true,
};
}
const adminOrigin = await getReusableAdminOrigin(projectRoot, {
requireDevMode: shouldRequireDevAdmin(projectRoot),
runtimeOrigin: options.runtimeOrigin,
});
if (!adminOrigin) {
return createStatusPayload(null);
}
const registration = await registerOfficialProject(projectRoot, adminOrigin);
return createStatusPayload(adminOrigin, registration);
}
function spawnMakeServer(projectRoot: string, options: MakeServerStartOptions = {}): Promise<void> {
const startCommand = resolveMakeServerStartCommand(projectRoot, options);
return new Promise((resolve, reject) => {
const child = spawn(startCommand.command, startCommand.args, {
cwd: projectRoot,
detached: true,
stdio: 'ignore',
});
child.once('error', reject);
child.once('spawn', () => {
child.unref();
console.log(`🚀 Starting Axhub Make server via ${startCommand.label}...`);
resolve();
});
});
}
export async function startOrReuseMakeServer(
projectRoot: string,
options: MakeServerStartOptions = {},
): Promise<MakeServerStatusPayload> {
if (startPromise) {
return startPromise;
}
startPromise = (async () => {
try {
const reuseOptions = {
requireDevMode: shouldRequireDevAdmin(projectRoot),
runtimeOrigin: options.runtimeOrigin,
healthTimeoutMs: options.healthTimeoutMs,
};
const reusableOrigin = await getReusableAdminOrigin(projectRoot, reuseOptions);
if (reusableOrigin) {
const registration = await registerOfficialProject(projectRoot, reusableOrigin);
return createStatusPayload(reusableOrigin, registration);
}
await spawnMakeServer(projectRoot, options);
const adminOrigin = await waitForAdminOrigin(projectRoot, {
...reuseOptions,
timeoutMs: options.adminReadyTimeoutMs,
pollIntervalMs: options.pollIntervalMs,
});
if (!adminOrigin) {
return {
...createStatusPayload(null),
error: `Started but did not become ready in time. Open make-server and register ${metadataPathForLog(projectRoot)} manually if needed.`,
};
}
const registration = await registerOfficialProject(projectRoot, adminOrigin);
return createStatusPayload(adminOrigin, registration);
} catch (error: any) {
return {
...createStatusPayload(null),
error: error?.message || String(error),
};
} finally {
startPromise = null;
}
})();
return startPromise;
}
function sendJson(res: ServerResponse, payload: unknown, status = 200) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(payload));
}
function handleMakeServerEndpoint(
req: IncomingMessage,
res: ServerResponse,
projectRoot: string,
options: MakeServerStartOptions = {},
): boolean {
const url = new URL(req.url || '/', 'http://localhost');
if (url.pathname === STATUS_ROUTE) {
if (req.method !== 'GET') {
sendJson(res, { error: 'Method not allowed' }, 405);
return true;
}
getRegisteredMakeServerStatus(projectRoot, options)
.then((payload) => sendJson(res, payload))
.catch((error: any) => sendJson(res, {
...createStatusPayload(null),
error: error?.message || String(error),
}));
return true;
}
if (url.pathname === START_ROUTE) {
if (req.method !== 'POST') {
sendJson(res, { error: 'Method not allowed' }, 405);
return true;
}
startOrReuseMakeServer(projectRoot, options)
.then((payload) => sendJson(res, payload))
.catch((error: any) => sendJson(res, {
...createStatusPayload(null),
error: error?.message || String(error),
}));
return true;
}
return false;
}
function resolveRuntimeOriginFromViteServer(server: any): string | undefined {
const actualPort = server.httpServer?.address()?.port || server.config.server?.port;
if (!actualPort) {
return undefined;
}
const configPath = path.resolve(process.cwd(), MAKE_CONFIG_RELATIVE_PATH);
let displayHost = 'localhost';
if (fs.existsSync(configPath)) {
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (typeof config?.server?.host === 'string' && config.server.host.trim()) {
displayHost = config.server.host.trim();
}
} catch {
// Ignore config parse errors and keep default.
}
}
return `http://${displayHost}:${actualPort}`;
}
export function autoStartMakeServerPlugin(): Plugin {
return {
name: 'auto-start-make-server',
configureServer(server: any) {
const projectRoot = path.resolve(process.cwd());
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
if (handleMakeServerEndpoint(req, res, projectRoot, {
runtimeOrigin: resolveRuntimeOriginFromViteServer(server),
})) {
return;
}
next();
});
if (process.env[SKIP_AUTO_START_SERVER_ENV] === '1') {
return;
}
server.httpServer?.once('listening', async () => {
try {
const payload = await startOrReuseMakeServer(projectRoot, {
runtimeOrigin: resolveRuntimeOriginFromViteServer(server),
});
if (payload.ready && payload.adminOrigin) {
console.log(`✅ Axhub Make server ready at ${payload.adminOrigin}`);
console.log(`✅ Registered ${payload.projectName || 'unnamed project'} in ${PRODUCT_NAME}; admin URL ${payload.adminUrl}`);
return;
}
console.warn(`[make-server] ${payload.error || `Open make-server and register ${metadataPathForLog(projectRoot)} manually if needed.`}`);
} catch (error: any) {
console.warn('[make-server] Auto-start failed:', error?.message || error);
}
});
},
};
}
function metadataPathForLog(projectRoot: string) {
return path.join(projectRoot, '.axhub/make/project.json');
}