Initial commit: OneOS prototype workspace baseline.

Include weekly report and other prototype sources with project tooling config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-06-26 15:36:52 +08:00
commit 7ef8852919
1310 changed files with 282074 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
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) {
return null;
}
const candidates = [
path.join(workspaceRoot, 'bin/cli.mjs'),
path.join(workspaceRoot, 'apps/make-server/bin/cli.mjs'),
];
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
}
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 args = [cliPath, projectRoot, '--dev'];
if (runtimeOrigin) {
args.push('--runtime-origin', runtimeOrigin);
}
return {
command: process.execPath,
args,
label: 'local @axhub/make dev',
};
}
const args = ['@axhub/make', 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 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');
}

View File

@@ -0,0 +1,69 @@
import type { Plugin } from 'vite';
import path from 'path';
/**
*本项目组件规范强制检查插件
* 1. 检查是否包含 default export
* 2. 在底部注入第三方平台所需的注册代码
*/
export function axhubComponentEnforcer(entryPath?: string): Plugin {
function resolveDefaultExportTarget(code: string, filePath: string) {
if (!/\bexport\s+default\b/.test(code)) {
throw new Error(`\n\n❌ 构建失败: ${filePath}\n必须包含 default export 以符合本项目组件规范。\n`);
}
const namedFunctionMatch = code.match(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/);
if (namedFunctionMatch) {
return {
transformedCode: code,
target: namedFunctionMatch[1]
};
}
const namedClassMatch = code.match(/export\s+default\s+class\s+([A-Za-z_$][\w$]*)\b/);
if (namedClassMatch) {
return {
transformedCode: code,
target: namedClassMatch[1]
};
}
const identifierMatch = code.match(/export\s+default\s+(?!function\b|class\b)([A-Za-z_$][\w$]*)\s*;?/);
if (identifierMatch) {
return {
transformedCode: code,
target: identifierMatch[1]
};
}
const exportDefaultPattern = /\bexport\s+default\s+/;
const replacedCode = code.replace(exportDefaultPattern, 'const __AXHUB_DEFAULT_COMPONENT__ = ');
if (replacedCode === code) {
throw new Error(`\n\n❌ 构建失败: ${filePath}\n无法解析 default export请使用标准导出语法。\n`);
}
return {
transformedCode: `${replacedCode}\n\nexport default __AXHUB_DEFAULT_COMPONENT__;\n`,
target: '__AXHUB_DEFAULT_COMPONENT__'
};
}
return {
name: 'axhub-component-enforcer',
enforce: 'pre',
transform(code, id) {
if (!entryPath || path.resolve(id) !== path.resolve(entryPath)) {
return null;
}
const { transformedCode, target } = resolveDefaultExportTarget(code, entryPath);
const injection = `
if (typeof window !== 'undefined' && window.__AXHUB_DEFINE_COMPONENT__) {
window.__AXHUB_DEFINE_COMPONENT__(${target});
}
`;
return transformedCode + injection;
}
};
}

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import {
canvasHotUpdateFilterPlugin,
isCanvasHotUpdateFile,
shouldDropCanvasFullReloadPayload,
} from './canvasHotUpdateFilter';
async function runConfigureServer(plugin: any, server: any) {
const hook = plugin.configureServer;
if (typeof hook === 'function') {
await hook(server);
} else {
await hook?.handler(server);
}
}
describe('canvasHotUpdateFilterPlugin', () => {
it('matches canvas data files outside the normal Vite refresh path', () => {
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/canvas.excalidraw')).toBe(true);
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/canvas-assets/screenshot.png')).toBe(true);
expect(isCanvasHotUpdateFile('src/prototypes/home/canvas-assets/embed.png')).toBe(true);
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/.spec/ai-image-history.json')).toBe(true);
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/.spec/review.md')).toBe(true);
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/index.tsx')).toBe(false);
});
it('returns an empty hot-update module list for canvas data changes', async () => {
const plugin = canvasHotUpdateFilterPlugin();
const handleHotUpdate = plugin.handleHotUpdate as any;
expect(await handleHotUpdate({
file: '/project/src/prototypes/home/canvas.excalidraw',
modules: [{ id: 'canvas' }],
})).toEqual([]);
expect(await handleHotUpdate({
file: '/project/src/prototypes/home/canvas-assets/screenshot.png',
modules: [{ id: 'screenshot' }],
})).toEqual([]);
expect(await handleHotUpdate({
file: '/project/src/prototypes/home/.spec/ai-image-history.json',
modules: [{ id: 'history' }],
})).toEqual([]);
expect(await handleHotUpdate({
file: '/project/src/prototypes/home/index.tsx',
modules: [{ id: 'index' }],
})).toBeUndefined();
});
it('drops Vite full reload payloads caused by canvas data files', async () => {
const hotSend = vi.fn();
const wsSend = vi.fn();
const server = {
hot: { send: hotSend },
ws: { send: wsSend },
};
const plugin = canvasHotUpdateFilterPlugin();
await runConfigureServer(plugin, server);
server.hot.send({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/canvas.excalidraw',
});
server.ws.send({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/canvas-assets/screenshot.png',
});
server.hot.send({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/.spec/review.md',
});
expect(hotSend).not.toHaveBeenCalled();
expect(wsSend).not.toHaveBeenCalled();
server.hot.send({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/index.tsx',
});
server.ws.send('custom:event', { ok: true });
expect(hotSend).toHaveBeenCalledTimes(1);
expect(wsSend).toHaveBeenCalledTimes(1);
});
it('identifies only canvas-triggered full reload payloads as droppable', () => {
expect(shouldDropCanvasFullReloadPayload({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/canvas.excalidraw',
})).toBe(true);
expect(shouldDropCanvasFullReloadPayload({
type: 'full-reload',
path: '/project/src/prototypes/home/canvas-assets/screenshot.png',
})).toBe(true);
expect(shouldDropCanvasFullReloadPayload({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/.spec/ai-image-history.json',
})).toBe(true);
expect(shouldDropCanvasFullReloadPayload({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/index.tsx',
})).toBe(false);
expect(shouldDropCanvasFullReloadPayload({
type: 'update',
triggeredBy: '/project/src/prototypes/home/canvas.excalidraw',
} as any)).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import { normalizePath, type HMRPayload, type Plugin, type ViteDevServer } from 'vite';
type SendFunction = (...args: any[]) => void;
const CANVAS_ASSETS_SEGMENT = '/canvas-assets/';
const SPEC_SEGMENT = '/.spec/';
export function isCanvasHotUpdateFile(filePath: string): boolean {
const normalized = normalizePath(filePath);
return (
normalized.endsWith('.excalidraw')
|| normalized.includes(CANVAS_ASSETS_SEGMENT)
|| normalized.includes(SPEC_SEGMENT)
);
}
function extractPayloadPath(payload: HMRPayload): string | null {
if (!payload || typeof payload !== 'object') {
return null;
}
if ('triggeredBy' in payload && typeof payload.triggeredBy === 'string') {
return payload.triggeredBy;
}
if ('path' in payload && typeof payload.path === 'string') {
return payload.path;
}
return null;
}
export function shouldDropCanvasFullReloadPayload(payload: HMRPayload): boolean {
if (!payload || typeof payload !== 'object' || payload.type !== 'full-reload') {
return false;
}
const payloadPath = extractPayloadPath(payload);
return payloadPath ? isCanvasHotUpdateFile(payloadPath) : false;
}
function patchSend(target: { send?: SendFunction } | null | undefined): void {
if (!target || typeof target.send !== 'function') {
return;
}
const originalSend = target.send.bind(target);
target.send = ((...args: any[]) => {
const payload = args[0];
if (shouldDropCanvasFullReloadPayload(payload)) {
return;
}
return originalSend(...args);
}) as SendFunction;
}
export function installCanvasFullReloadFilter(server: Pick<ViteDevServer, 'hot' | 'ws'>): void {
patchSend(server.hot as unknown as { send?: SendFunction });
patchSend(server.ws as unknown as { send?: SendFunction });
}
export function canvasHotUpdateFilterPlugin(): Plugin {
return {
name: 'axhub-canvas-hot-update-filter',
apply: 'serve',
enforce: 'pre',
configureServer(server) {
installCanvasFullReloadFilter(server);
},
handleHotUpdate(ctx) {
if (isCanvasHotUpdateFile(ctx.file)) {
return [];
}
return undefined;
},
};
}

View File

@@ -0,0 +1,480 @@
import fs from 'node:fs';
import path from 'node:path';
import type { Plugin } from 'vite';
import {
fetchHealth,
normalizeHealthServerInfo,
readServerInfo,
} from '../scripts/utils/serverInfo.mjs';
import { buildPreviewTitle, readEntryDisplayName } from './utils/previewTitle';
type ResourceType = 'prototypes' | 'themes';
interface AxhubServerInfo {
pid: number;
port: number;
host: string;
origin: string;
projectRoot: string;
startedAt: string;
}
const PREVIEW_TYPES = new Set<ResourceType>(['prototypes', 'themes']);
const PROTOTYPE_CANVAS_ASSETS_DIR = 'canvas-assets';
function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function encodeRoutePath(pathname: string): string {
const hasLeadingSlash = pathname.startsWith('/');
const encoded = pathname
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(decodeURIComponent(segment)))
.join('/');
return hasLeadingSlash ? `/${encoded}` : encoded;
}
function createRouteBaseHref(type: ResourceType, name: string): string {
return `${encodeRoutePath(`/${type}/${name}`)}/`;
}
function createPreviewTransformUrl(type: ResourceType, name: string): string {
return createRouteBaseHref(type, name);
}
function normalizeRoute(url: string): { type: ResourceType; name: string; action: 'preview' | 'spec'; assetPath?: string } | null {
const pathname = (url.split('?')[0] || '').replace(/\/index\.html$/u, '').replace(/\.html$/u, '');
const parts = pathname.split('/').filter(Boolean).map((part) => {
try {
return decodeURIComponent(part);
} catch {
return part;
}
});
const type = parts[0] as ResourceType;
if (!PREVIEW_TYPES.has(type) || parts.length < 2) {
return null;
}
const lastPart = parts[parts.length - 1] || '';
const isAssetRequest = /\.(css|png|jpe?g|webp|svg)$/iu.test(lastPart);
const action = parts[parts.length - 1] === 'spec' ? 'spec' : 'preview';
let nameParts = action === 'spec' || isAssetRequest ? parts.slice(1, -1) : parts.slice(1);
let assetParts = isAssetRequest ? [lastPart] : [];
if (isAssetRequest) {
const canvasAssetsIndex = parts.indexOf(PROTOTYPE_CANVAS_ASSETS_DIR, 1);
if (canvasAssetsIndex > 1) {
nameParts = parts.slice(1, canvasAssetsIndex);
assetParts = parts.slice(canvasAssetsIndex);
}
}
const name = nameParts.join('/');
if (!name || nameParts.some((part) => part === '..')) {
return null;
}
const assetPath = assetParts.join('/');
if (assetPath && assetPath.split('/').some((part) => !part || part === '..')) {
return null;
}
return { type, name, action, ...(assetPath ? { assetPath } : {}) };
}
function isHtmlProxyModuleRequest(url: string): boolean {
return /[?&]html-proxy\b/u.test(url);
}
function readTemplate(projectRoot: string, name: string) {
const templatePath = path.resolve(projectRoot, 'src/preview-templates', name);
return fs.readFileSync(templatePath, 'utf8');
}
export function createQuickEditRuntimeScriptTag(serverOrigin: string | null | undefined): string {
const origin = String(serverOrigin || '').trim().replace(/\/+$/u, '');
if (!origin) {
return '';
}
return `<script data-axhub-quick-edit-runtime src="${origin}/runtime/quick-edit.js"></script>`;
}
export function createDevTemplateBootstrapScriptTag(serverOrigin: string | null | undefined): string {
const origin = String(serverOrigin || '').trim().replace(/\/+$/u, '');
if (!origin) {
return '';
}
return `<script type="module" data-axhub-dev-template-bootstrap src="${origin}/assets/dev-template-bootstrap.js"></script>`;
}
export function injectDevTemplateBootstrapScript(html: string, serverOrigin: string | null | undefined): string {
if (!serverOrigin || html.includes('data-axhub-dev-template-bootstrap')) {
return html;
}
const tag = createDevTemplateBootstrapScriptTag(serverOrigin);
if (!tag) {
return html;
}
const previewLoaderModuleScriptPattern = /(\s*<script\b[^>]*type=["']module["'][^>]*>\s*)\{\{PREVIEW_LOADER\}\}/u;
if (previewLoaderModuleScriptPattern.test(html)) {
return html.replace(previewLoaderModuleScriptPattern, (match, scriptStart: string) => {
const leadingWhitespace = scriptStart.match(/^\s*/u)?.[0] ?? '\n';
return `${leadingWhitespace}${tag}${scriptStart.slice(leadingWhitespace.length)}{{PREVIEW_LOADER}}`;
});
}
if (html.includes('{{PREVIEW_LOADER}}')) {
return html.replace('{{PREVIEW_LOADER}}', `${tag}\n{{PREVIEW_LOADER}}`);
}
return html.includes('</body>')
? html.replace('</body>', ` ${tag}\n</body>`)
: `${html}\n${tag}`;
}
export function injectQuickEditRuntimeScript(html: string, serverOrigin: string | null | undefined): string {
if (!serverOrigin || html.includes('data-axhub-quick-edit-runtime')) {
return html;
}
const tag = createQuickEditRuntimeScriptTag(serverOrigin);
if (!tag) {
return html;
}
return html.includes('</body>')
? html.replace('</body>', ` ${tag}\n</body>`)
: `${html}\n${tag}`;
}
export function injectPreviewScrollbarStyle(html: string): string {
if (html.includes('data-axhub-preview-scrollbar-style')) {
return html;
}
const tag = `<style data-axhub-preview-scrollbar-style>
html,
body {
scrollbar-width: none;
-ms-overflow-style: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
body {
overflow-x: hidden;
}
</style>`;
return html.includes('</head>')
? html.replace('</head>', ` ${tag}\n</head>`)
: `${tag}\n${html}`;
}
function createPreviewLoader(type: ResourceType, name: string, projectRoot: string) {
const importPath = `/${type}/${name}/index.tsx`;
const previewPath = `/${type}/${name}`;
return `
import React from 'react';
import { createRoot } from 'react-dom/client';
import PreviewComponent from ${JSON.stringify(importPath)};
function notifyAxhubPreviewUpdated(reason) {
if (typeof window === 'undefined' || window.parent === window) return;
window.parent.postMessage({
type: 'AXHUB_PREVIEW_UPDATED',
reason,
path: ${JSON.stringify(previewPath)},
updatedAt: Date.now(),
}, '*');
}
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('[Axhub Make Project] Missing #root container');
}
const root = createRoot(rootElement);
root.render(React.createElement(PreviewComponent, {
container: rootElement,
config: {
projectPath: ${JSON.stringify(projectRoot)},
},
data: {},
events: {},
}));
if (import.meta.hot) {
import.meta.hot.accept(${JSON.stringify(importPath)}, (nextModule) => {
const NextComponent = nextModule?.default || PreviewComponent;
root.render(React.createElement(NextComponent, {
container: rootElement,
config: {
projectPath: ${JSON.stringify(projectRoot)},
},
data: {},
events: {},
}));
notifyAxhubPreviewUpdated('hmr');
});
}
`;
}
function sendPreviewFile(res: {
statusCode?: number;
setHeader(name: string, value: string): void;
end(data?: string | Buffer): void;
}, filePath: string): boolean {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return false;
}
const ext = path.extname(filePath).toLowerCase();
const contentTypes: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
};
res.statusCode = 200;
res.setHeader('Content-Type', contentTypes[ext] || 'application/octet-stream');
res.setHeader('Cache-Control', 'no-store');
res.end(fs.readFileSync(filePath));
return true;
}
function getHeaderValue(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
function isCssModuleRequest(
req: { headers?: Record<string, string | string[] | undefined> },
assetPath: string,
): boolean {
if (path.extname(assetPath).toLowerCase() !== '.css') {
return false;
}
if (getHeaderValue(req.headers?.['sec-fetch-dest']).toLowerCase() === 'script') {
return true;
}
const accept = getHeaderValue(req.headers?.accept).toLowerCase();
if (accept && !accept.includes('text/css')) {
return true;
}
const referer = getHeaderValue(req.headers?.referer || req.headers?.referrer).trim();
if (!referer) {
return false;
}
try {
const pathname = new URL(referer).pathname;
return /\.(?:[cm]?[jt]sx?|mjs)$/iu.test(pathname);
} catch {
return false;
}
}
function resolvePreviewAssetPath(projectRoot: string, route: {
type: ResourceType;
name: string;
assetPath: string;
}): string | null {
const resourceDir = path.resolve(projectRoot, 'src', route.type, route.name);
const assetPath = route.assetPath.replace(/\\/gu, '/');
const assetParts = assetPath.split('/').filter(Boolean);
if (assetParts.length === 0 || assetParts.some((part) => part === '..')) {
return null;
}
if (
assetParts.length > 1
&& (route.type !== 'prototypes' || assetParts[0] !== PROTOTYPE_CANVAS_ASSETS_DIR)
) {
return null;
}
const resolvedPath = path.resolve(resourceDir, ...assetParts);
const relative = path.relative(resourceDir, resolvedPath);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
return null;
}
return resolvedPath;
}
function getRequestRefererOrigin(req: { headers?: Record<string, string | string[] | undefined> }): string | null {
const referer = getHeaderValue(req.headers?.referer || req.headers?.referrer).trim();
if (!referer) {
return null;
}
try {
return new URL(referer).origin;
} catch {
return null;
}
}
function isLocalHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase().replace(/^\[/u, '').replace(/\]$/u, '');
return !normalized
|| normalized === 'localhost'
|| normalized === '0.0.0.0'
|| normalized === '::'
|| normalized === '::1'
|| /^127(?:\.\d{1,3}){3}$/u.test(normalized);
}
function formatUrlHost(hostname: string): string {
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname;
}
function getRequestProtocol(req: { headers?: Record<string, string | string[] | undefined> }): 'http' | 'https' {
const forwardedProto = getHeaderValue(req.headers?.['x-forwarded-proto']).split(',')[0]?.trim().toLowerCase();
return forwardedProto === 'https' ? 'https' : 'http';
}
function createNetworkAdminOriginFromRequestHost(
req: { headers?: Record<string, string | string[] | undefined> },
adminInfo: AxhubServerInfo | null,
): string | null {
if (!adminInfo?.port) {
return null;
}
const hostHeader = getHeaderValue(req.headers?.['x-forwarded-host'] || req.headers?.host).trim();
if (!hostHeader) {
return null;
}
try {
const requestHost = new URL(`http://${hostHeader}`).hostname;
if (isLocalHostname(requestHost)) {
return null;
}
return `${getRequestProtocol(req)}://${formatUrlHost(requestHost)}:${adminInfo.port}`;
} catch {
return null;
}
}
function isAdminHealthPayload(data: unknown): boolean {
return Boolean(data && typeof data === 'object' && (data as { role?: unknown }).role === 'admin');
}
async function resolveAdminServerOrigin(
projectRoot: string,
req: { headers?: Record<string, string | string[] | undefined> },
): Promise<string | null> {
const embeddedAdminOrigin = getRequestRefererOrigin(req);
if (embeddedAdminOrigin) {
const health = await fetchHealth(embeddedAdminOrigin, 600);
if (isAdminHealthPayload(health) && normalizeHealthServerInfo(health)?.origin) {
return embeddedAdminOrigin;
}
}
const info = readServerInfo(projectRoot, 'admin');
const requestHostAdminOrigin = createNetworkAdminOriginFromRequestHost(req, info);
if (requestHostAdminOrigin) {
const health = await fetchHealth(requestHostAdminOrigin, 600);
if (isAdminHealthPayload(health) && normalizeHealthServerInfo(health)?.origin) {
return requestHostAdminOrigin;
}
}
return info?.origin || null;
}
export function clientPreviewPlugin(): Plugin {
const projectRoot = process.cwd();
return {
name: 'make-project-client-preview',
apply: 'serve',
async configureServer(server) {
server.middlewares.use(async (req, res, next) => {
try {
if (!req.url || req.method !== 'GET') {
next();
return;
}
if (isHtmlProxyModuleRequest(req.url)) {
next();
return;
}
const route = normalizeRoute(req.url);
if (!route) {
next();
return;
}
const entryPath = path.resolve(projectRoot, 'src', route.type, route.name, 'index.tsx');
if (!fs.existsSync(entryPath)) {
next();
return;
}
if (route.assetPath) {
if (isCssModuleRequest(req, route.assetPath)) {
next();
return;
}
const assetPath = resolvePreviewAssetPath(projectRoot, {
type: route.type,
name: route.name,
assetPath: route.assetPath,
});
if (assetPath && sendPreviewFile(res, assetPath)) {
return;
}
next();
return;
}
if (route.action === 'spec') {
next();
return;
}
const title = buildPreviewTitle({
group: route.type,
name: route.name,
displayName: readEntryDisplayName(entryPath),
mode: 'dev',
});
const template = readTemplate(projectRoot, 'dev-template.html');
const serverOrigin = await resolveAdminServerOrigin(projectRoot, req);
let html = template
.replace(/\{\{TITLE\}\}/g, title)
.replace(
'</head>',
` <base href="${createRouteBaseHref(route.type, route.name)}">\n</head>`,
);
html = injectPreviewScrollbarStyle(html);
const stylePath = path.resolve(projectRoot, 'src', route.type, route.name, 'style.css');
if (fs.existsSync(stylePath)) {
html = html.replace(
'</head>',
` <link rel="stylesheet" href="/${route.type}/${route.name}/style.css">\n</head>`,
);
}
html = injectDevTemplateBootstrapScript(html, serverOrigin);
html = html.replace(/\{\{PREVIEW_LOADER\}\}/g, createPreviewLoader(route.type, route.name, projectRoot));
html = injectQuickEditRuntimeScript(html, serverOrigin);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(await server.transformIndexHtml(
createPreviewTransformUrl(route.type, route.name),
html,
));
} catch (error) {
next(error);
}
});
},
transformIndexHtml(html) {
if (!html.includes('{{PREVIEW_LOADER}}')) {
return html;
}
return html.replace(new RegExp(escapeRegExp('{{PREVIEW_LOADER}}'), 'g'), '');
},
};
}

View File

@@ -0,0 +1,19 @@
import type { Plugin } from 'vite';
export function forceInlineDynamicImportsOff(enable: boolean): Plugin {
return {
name: 'force-inline-dynamic-imports-off',
configResolved(config) {
if (!enable) {
return;
}
const output = config.build.rollupOptions.output;
const outputs = Array.isArray(output) ? output : output ? [output] : [];
outputs.forEach((item) => {
if (item) {
item.inlineDynamicImports = false;
}
});
}
};
}

View File

@@ -0,0 +1,113 @@
import type { Plugin } from 'vite';
import { createHash } from 'crypto';
import path from 'path';
/**
* 为每个页面/组件文件注入稳定的唯一 ID
* 基于文件相对路径生成,保证构建间稳定不变
*
* 生成的 ID 格式:{项目名}-{目录类型}-{项目名称}-{16位哈希}
* 例如axhub-make-prototypes-demo-antd-a1b2c3d4e5f6g7h8
*
* 规则:
* - 忽略 index.tsx 中的 index 部分(因为都是同一个,没有意义)
* - 使用项目目录名(如 axhub-make
* - 根据 components 和 prototypes 目录区分
* - 加上文件项目名称
*/
export function injectStablePageIds(): Plugin {
const cwd = process.cwd();
// 从 cwd 获取项目目录名(如 axhub-make
const projectName = path.basename(cwd);
return {
name: 'inject-stable-page-ids',
enforce: 'pre',
transform(code, id) {
// 只处理 tsx/jsx 文件
if (!/\.(tsx|jsx)$/.test(id)) return null;
// 获取相对路径
const relativePath = path.relative(cwd, id).replace(/\\/g, '/');
// 生成长哈希16位 SHA-256极低重复风险
const longHash = createHash('sha256')
.update(relativePath)
.digest('hex')
.slice(0, 16);
// 解析路径src/prototypes/demo-antd/index.tsx 或 src/components/demo-button/index.tsx
// 目标格式axhub-make-prototypes-demo-antd 或 axhub-make-components-demo-button
// 规则:忽略 index.tsx 中的 index 部分,使用目录名作为项目名称
const pathParts = relativePath
.replace(/^src\//, '') // 移除 src/ 前缀
.replace(/\.(tsx|jsx)$/, '') // 移除文件扩展名
.split('/');
// 查找 prototypes 或 components 目录
const categoryIndex = pathParts.findIndex(part => part === 'prototypes' || part === 'components');
let readableId: string;
if (categoryIndex >= 0 && categoryIndex < pathParts.length - 1) {
// 找到目录类型prototypes 或 components
const category = pathParts[categoryIndex];
// 获取项目名称category 后面的第一个非 index 部分)
// 例如prototypes/demo-antd/index -> demo-antd
// prototypes/demo-antd/some-file -> demo-antd
// prototypes/index -> '' (空,使用降级方案)
let itemName = '';
// 从 category 后面开始查找项目名称
for (let i = categoryIndex + 1; i < pathParts.length; i++) {
const part = pathParts[i];
// 如果遇到 index跳过它继续查找下一级
if (part === 'index') {
continue;
}
// 找到第一个非 index 的部分,作为项目名称
itemName = part;
break;
}
// 组合:项目名-目录类型-项目名称
readableId = `${projectName}-${category}${itemName ? '-' + itemName : ''}`;
} else {
// 降级方案:如果路径不符合预期,使用原来的逻辑但移除 index
readableId = relativePath
.replace(/^src\//, '')
.replace(/\.(tsx|jsx)$/, '')
.replace(/\/index$/, '') // 移除末尾的 /index
.replace(/[\/\.]/g, '-')
.replace(/^-+|-+$/g, '') // 移除首尾的连字符
.slice(0, 48);
// 如果 readableId 不以项目名开头,则添加
if (!readableId.startsWith(projectName)) {
readableId = `${projectName}-${readableId}`;
}
}
// 组合 ID可读路径 + 哈希
const stableId = `${readableId}-${longHash}`;
// 注入全局常量,组件中可直接使用
const injectedCode = `
// Auto-injected by vite-plugin-inject-stable-page-ids
const __PAGE_ID__ = '${stableId}';
const __PAGE_PATH__ = '${readableId}';
const __PAGE_FULL_PATH__ = '${relativePath}';
const __PAGE_HASH__ = '${longHash}';
${code}
`.trim();
return {
code: injectedCode,
map: null
};
}
};
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import {
findListeningPidsOnPort,
releaseListeningProcessesOnPort,
} from './utils/portOccupancy';
describe('client port occupancy helpers', () => {
it('reads listening PIDs with lsof on macOS and Linux', () => {
const spawnSync = vi.fn(() => ({
stdout: '123\n456\n123\n',
stderr: '',
status: 0,
})) as any;
expect(findListeningPidsOnPort(51720, {
platform: 'darwin',
spawnSync,
})).toEqual([123, 456]);
expect(spawnSync).toHaveBeenCalledWith('lsof', [
'-tiTCP',
':51720',
'-sTCP:LISTEN',
], expect.objectContaining({ encoding: 'utf8' }));
});
it('terminates other listening processes while ignoring the current process', () => {
const spawnSync = vi.fn(() => ({
stdout: '111\n222\n',
stderr: '',
status: 0,
})) as any;
const killPid = vi.fn();
expect(releaseListeningProcessesOnPort(51720, {
platform: 'linux',
spawnSync,
killPid,
currentPid: 111,
waitMs: 0,
})).toEqual([222]);
expect(killPid).toHaveBeenCalledWith(222, 'SIGTERM');
});
});

View File

@@ -0,0 +1,55 @@
import {
getEntriesPath as getEntriesPathCore,
migrateLegacyEntries as migrateLegacyEntriesCore,
readEntriesManifest as readEntriesManifestCore,
scanProjectEntries as scanProjectEntriesCore,
toCompatMaps as toCompatMapsCore,
writeEntriesManifestAtomic as writeEntriesManifestAtomicCore,
} from './entriesManifestCore.js';
export type EntryGroup = 'components' | 'prototypes' | 'themes';
export interface EntriesManifestItem {
group: string;
name: string;
js: string;
html: string;
}
export interface EntriesManifestV2 {
schemaVersion: 2;
generatedAt: string;
items: Record<string, EntriesManifestItem>;
js: Record<string, string>;
html: Record<string, string>;
}
export function toCompatMaps(items: Record<string, EntriesManifestItem>): {
js: Record<string, string>;
html: Record<string, string>;
} {
return toCompatMapsCore(items);
}
export function scanProjectEntries(
projectRoot: string,
groups: EntryGroup[] = ['components', 'prototypes', 'themes'],
): EntriesManifestV2 {
return scanProjectEntriesCore(projectRoot, groups);
}
export function migrateLegacyEntries(raw: unknown, projectRoot: string): EntriesManifestV2 {
return migrateLegacyEntriesCore(raw, projectRoot);
}
export function writeEntriesManifestAtomic(projectRoot: string, manifest: EntriesManifestV2): EntriesManifestV2 {
return writeEntriesManifestAtomicCore(projectRoot, manifest);
}
export function readEntriesManifest(projectRoot: string): EntriesManifestV2 {
return readEntriesManifestCore(projectRoot);
}
export function getEntriesPath(projectRoot: string): string {
return getEntriesPathCore(projectRoot);
}

View File

@@ -0,0 +1,24 @@
export interface EntriesManifestItem {
group: string;
name: string;
js: string;
html: string;
}
export interface EntriesManifestV2 {
schemaVersion: 2;
generatedAt: string;
items: Record<string, EntriesManifestItem>;
js: Record<string, string>;
html: Record<string, string>;
}
export function toCompatMaps(items: Record<string, EntriesManifestItem>): {
js: Record<string, string>;
html: Record<string, string>;
};
export function scanProjectEntries(projectRoot: string, groups?: string[]): EntriesManifestV2;
export function migrateLegacyEntries(raw: unknown, projectRoot: string): EntriesManifestV2;
export function writeEntriesManifestAtomic(projectRoot: string, manifest: EntriesManifestV2): EntriesManifestV2;
export function readEntriesManifest(projectRoot: string): EntriesManifestV2;
export function getEntriesPath(projectRoot: string): string;

View File

@@ -0,0 +1,247 @@
import fs from 'fs';
import path from 'path';
const DEFAULT_GROUPS = ['components', 'prototypes', 'themes'];
const SCHEMA_VERSION = 2;
const ENTRIES_RELATIVE_PATH = path.join('.axhub', 'make', 'entries.json');
function toPosixPath(input) {
return String(input || '').split(path.sep).join('/');
}
function normalizeRelativePath(projectRoot, filePath) {
if (!filePath || typeof filePath !== 'string') {
return '';
}
const absoluteCandidate = path.isAbsolute(filePath)
? filePath
: path.resolve(projectRoot, filePath);
const relative = path.relative(projectRoot, absoluteCandidate);
if (!relative || relative.startsWith('..')) {
return toPosixPath(filePath).replace(/^\.?\//, '');
}
return toPosixPath(relative).replace(/^\.?\//, '');
}
function sortRecordByKey(record) {
const next = {};
Object.keys(record || {})
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
next[key] = record[key];
});
return next;
}
function normalizeItemKey(key) {
const normalized = String(key || '').trim().replace(/\\/g, '/');
if (!normalized || !normalized.includes('/')) return '';
return normalized.replace(/^\/+/, '');
}
function parseKey(key) {
const normalized = normalizeItemKey(key);
if (!normalized) return null;
const [group, ...nameParts] = normalized.split('/');
const name = nameParts.join('/');
if (!group || !name) return null;
return { group, name };
}
function sanitizeItem(item, projectRoot, fallbackKey) {
const keyInfo = parseKey(fallbackKey);
if (!keyInfo) return null;
const group = String(item?.group || keyInfo.group).trim();
const name = String(item?.name || keyInfo.name).trim();
if (!group || !name) return null;
const key = `${group}/${name}`;
const js = normalizeRelativePath(
projectRoot,
item?.js || `src/${group}/${name}/index.tsx`,
);
const html = normalizeRelativePath(
projectRoot,
item?.html || `src/${group}/${name}/index.html`,
);
return {
key,
item: {
group,
name,
js,
html,
},
};
}
function buildManifestFromItems(items, generatedAt) {
const compat = toCompatMaps(items);
return {
schemaVersion: SCHEMA_VERSION,
generatedAt: generatedAt || new Date().toISOString(),
items,
js: compat.js,
html: compat.html,
};
}
function normalizeManifest(raw, projectRoot, generatedAt) {
const nextItems = {};
const sourceItems =
raw && typeof raw === 'object' && raw.items && typeof raw.items === 'object'
? raw.items
: {};
Object.keys(sourceItems)
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
const sanitized = sanitizeItem(sourceItems[key], projectRoot, key);
if (sanitized) {
nextItems[sanitized.key] = sanitized.item;
}
});
return buildManifestFromItems(nextItems, generatedAt);
}
export function toCompatMaps(items) {
const js = {};
const html = {};
Object.keys(items || {})
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
const item = items[key];
if (!item || typeof item !== 'object') return;
const jsPath = typeof item.js === 'string' ? item.js.trim() : '';
const htmlPath = typeof item.html === 'string' ? item.html.trim() : '';
if (jsPath) {
js[key] = jsPath;
}
if (htmlPath) {
html[key] = htmlPath;
}
});
return {
js: sortRecordByKey(js),
html: sortRecordByKey(html),
};
}
export function scanProjectEntries(projectRoot, groups = DEFAULT_GROUPS) {
const root = path.resolve(projectRoot, 'src');
const items = {};
for (const group of groups) {
const groupDir = path.join(root, group);
if (!fs.existsSync(groupDir)) continue;
const names = fs
.readdirSync(groupDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
for (const name of names) {
const jsEntry = path.join(groupDir, name, 'index.tsx');
if (!fs.existsSync(jsEntry)) continue;
const key = `${group}/${name}`;
items[key] = {
group,
name,
js: toPosixPath(path.relative(projectRoot, jsEntry)),
html: toPosixPath(path.relative(projectRoot, path.join(groupDir, name, 'index.html'))),
};
}
}
return buildManifestFromItems(sortRecordByKey(items));
}
export function migrateLegacyEntries(raw, projectRoot) {
if (raw && typeof raw === 'object' && raw.schemaVersion === SCHEMA_VERSION && raw.items) {
return normalizeManifest(raw, projectRoot, raw.generatedAt);
}
const legacyJs =
raw && typeof raw === 'object' && raw.js && typeof raw.js === 'object'
? raw.js
: {};
const legacyHtml =
raw && typeof raw === 'object' && raw.html && typeof raw.html === 'object'
? raw.html
: {};
const keys = new Set([
...Object.keys(legacyJs),
...Object.keys(legacyHtml),
]);
const items = {};
Array.from(keys)
.sort((a, b) => a.localeCompare(b))
.forEach((key) => {
const parsed = parseKey(key);
if (!parsed) return;
const js = normalizeRelativePath(
projectRoot,
legacyJs[key] || `src/${parsed.group}/${parsed.name}/index.tsx`,
);
const html = normalizeRelativePath(
projectRoot,
legacyHtml[key] || `src/${parsed.group}/${parsed.name}/index.html`,
);
items[key] = {
group: parsed.group,
name: parsed.name,
js,
html,
};
});
return buildManifestFromItems(items);
}
export function writeEntriesManifestAtomic(projectRoot, manifest) {
const entriesPath = getEntriesPath(projectRoot);
const normalized = normalizeManifest(manifest, projectRoot, manifest?.generatedAt);
const tempPath = `${entriesPath}.tmp-${process.pid}-${Date.now()}`;
fs.mkdirSync(path.dirname(entriesPath), { recursive: true });
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, entriesPath);
return normalized;
}
export function readEntriesManifest(projectRoot) {
const entriesPath = getEntriesPath(projectRoot);
if (!fs.existsSync(entriesPath)) {
const scanned = scanProjectEntries(projectRoot, DEFAULT_GROUPS);
return writeEntriesManifestAtomic(projectRoot, scanned);
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(entriesPath, 'utf8'));
} catch {
raw = {};
}
const migrated = migrateLegacyEntries(raw, projectRoot);
const rawString = JSON.stringify(raw);
const nextString = JSON.stringify(migrated);
if (rawString !== nextString) {
return writeEntriesManifestAtomic(projectRoot, migrated);
}
return migrated;
}
export function getEntriesPath(projectRoot) {
return path.resolve(projectRoot, ENTRIES_RELATIVE_PATH);
}

View File

@@ -0,0 +1,15 @@
import { networkInterfaces } from 'node:os';
export function getLocalIP(): string {
const interfaces = networkInterfaces();
for (const nets of Object.values(interfaces)) {
for (const net of nets || []) {
if (net.family === 'IPv4' && !net.internal) {
return net.address;
}
}
}
return 'localhost';
}

View File

@@ -0,0 +1,7 @@
import path from 'path';
export const MAKE_STATE_DIR = path.join('.axhub', 'make');
export const MAKE_CONFIG_RELATIVE_PATH = path.join(MAKE_STATE_DIR, 'axhub.config.json');
export const MAKE_DEV_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.dev-server-info.json');
export const MAKE_ENTRIES_RELATIVE_PATH = path.join(MAKE_STATE_DIR, 'entries.json');
export const AXURE_BRIDGE_BASE_URL = 'http://localhost:32767';

View File

@@ -0,0 +1,103 @@
import { spawnSync } from 'node:child_process';
export interface PortProcessLookup {
platform?: NodeJS.Platform;
spawnSync?: typeof spawnSync;
}
export interface ReleasePortOptions extends PortProcessLookup {
killPid?: (pid: number, signal?: NodeJS.Signals) => void;
currentPid?: number;
waitMs?: number;
}
function parsePidList(output: string): number[] {
const seen = new Set<number>();
for (const line of output.split(/\r?\n/u)) {
const pid = Number(line.trim());
if (Number.isInteger(pid) && pid > 0) {
seen.add(pid);
}
}
return Array.from(seen);
}
export function findListeningPidsOnPort(port: number, options: PortProcessLookup = {}): number[] {
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return [];
}
const platform = options.platform || process.platform;
const run = options.spawnSync || spawnSync;
if (platform === 'win32') {
const result = run('powershell.exe', [
'-NoProfile',
'-Command',
`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`,
], {
encoding: 'utf8',
windowsHide: true,
timeout: 2000,
});
return parsePidList(String(result.stdout || ''));
}
const result = run('lsof', [
'-tiTCP',
`:${port}`,
'-sTCP:LISTEN',
], {
encoding: 'utf8',
timeout: 2000,
});
return parsePidList(String(result.stdout || ''));
}
function waitForPortRelease(port: number, options: ReleasePortOptions): void {
const deadline = Date.now() + Math.max(0, options.waitMs ?? 1500);
while (Date.now() < deadline) {
if (findListeningPidsOnPort(port, options).length === 0) {
return;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
}
}
export function releaseListeningProcessesOnPort(port: number, options: ReleasePortOptions = {}): number[] {
const currentPid = options.currentPid ?? process.pid;
const pids = findListeningPidsOnPort(port, options).filter((pid) => pid !== currentPid);
if (pids.length === 0) {
return [];
}
if ((options.platform || process.platform) === 'win32') {
const run = options.spawnSync || spawnSync;
for (const pid of pids) {
run('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
encoding: 'utf8',
windowsHide: true,
timeout: 5000,
});
}
} else {
const killPid = options.killPid || process.kill.bind(process);
for (const pid of pids) {
try {
killPid(pid, 'SIGTERM');
} catch {
// Process may have exited between lookup and signal.
}
}
waitForPortRelease(port, options);
for (const pid of findListeningPidsOnPort(port, options).filter((pid) => pid !== currentPid)) {
try {
killPid(pid, 'SIGKILL');
} catch {
// Process may have exited after the second lookup.
}
}
}
return pids;
}

View File

@@ -0,0 +1,40 @@
import fs from 'node:fs';
export type PreviewResourceGroup = 'components' | 'prototypes' | 'themes';
export type PreviewTitleMode = 'dev' | 'export';
export function readEntryDisplayName(indexFilePath: string): string | null {
try {
const content = fs.readFileSync(indexFilePath, 'utf8');
const match = content.match(/@name\s+([^\n]+)/);
const displayName = match?.[1]?.replace(/\*\/\s*$/, '').trim();
return displayName || null;
} catch {
return null;
}
}
export function buildPreviewTitle(options: {
group: PreviewResourceGroup;
name: string;
displayName?: string | null;
mode: PreviewTitleMode;
}): string {
const label = String(options.displayName || options.name || '').trim() || '未命名预览';
const suffixMap: Record<PreviewResourceGroup, Record<PreviewTitleMode, string>> = {
components: {
dev: '组件预览(开发)',
export: '组件预览',
},
prototypes: {
dev: '原型预览(开发)',
export: '原型预览',
},
themes: {
dev: '主题预览(开发)',
export: '主题预览',
},
};
return `${label} - ${suffixMap[options.group][options.mode]}`;
}

View File

@@ -0,0 +1,812 @@
import fs from 'node:fs';
import type { IncomingMessage } from 'node:http';
import path from 'node:path';
import type { Plugin, ViteDevServer } from 'vite';
import { WebSocket, WebSocketServer } from 'ws';
import type { RawData } from 'ws';
type ExtractZip = typeof import('extract-zip');
// Lazy-loaded to avoid pulling in iconv-lite at Vite config time.
let _extractZip: ExtractZip | null = null;
let _runCommand: typeof import('../scripts/utils/command-runtime.mjs').runCommand | null = null;
async function getExtractZip() {
if (!_extractZip) {
const module = await import('extract-zip') as unknown as { default?: ExtractZip };
_extractZip = module.default || (module as ExtractZip);
}
return _extractZip;
}
async function getRunCommand() {
if (!_runCommand) {
_runCommand = (await import('../scripts/utils/command-runtime.mjs')).runCommand;
}
return _runCommand;
}
export interface WebSocketMessage {
type: string;
data?: unknown;
payload?: unknown;
client?: string;
version?: string;
widgetId?: string;
pageId?: string;
blurImages?: unknown;
metadata?: unknown;
}
export interface ClientMeta {
id: number;
type: string;
version?: string;
address?: string;
connectedAt: number;
}
interface UploadSession {
transferId: string;
pageName: string;
displayName?: string;
outputRelativeDir: string;
fileName: string;
mode: 'zip' | 'files';
totalChunks: number;
totalBytes?: number;
receivedChunks: number;
receivedBytes: number;
chunks: Map<number, Buffer>;
filesRoot?: string;
filesReceived: number;
startedAt: number;
}
interface HandleMessageContext {
clientMeta: Map<WebSocket, ClientMeta>;
uploadSessions: Map<string, UploadSession>;
projectRoot: string;
}
type MiddlewareResponse = {
statusCode: number;
setHeader(name: string, value: string): void;
end(chunk?: unknown): void;
};
const WS_PATH = '/ws';
const IGNORED_EXTRACT_ENTRIES = new Set(['__MACOSX', '.DS_Store']);
const nodeCommand = process.execPath;
/* ------------------------------------------------------------------ */
/* Utility helpers */
/* ------------------------------------------------------------------ */
function ensureDir(dirPath: string) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function inferExtractedRootFolder(extractDir: string) {
if (!fs.existsSync(extractDir)) {
return { entryCount: 0, hasRootFolder: false, rootFolderName: '' };
}
const entries = fs
.readdirSync(extractDir, { withFileTypes: true })
.filter(entry => !IGNORED_EXTRACT_ENTRIES.has(entry.name));
if (entries.length === 1 && entries[0].isDirectory()) {
return { entryCount: entries.length, hasRootFolder: true, rootFolderName: entries[0].name };
}
return { entryCount: entries.length, hasRootFolder: false, rootFolderName: '' };
}
function isSafeName(value: string) {
return Boolean(value && value.trim() && !value.includes('..') && !/[\\/]/.test(value));
}
function isSafeRelativePath(value: string) {
if (!value || typeof value !== 'string') return false;
const normalized = value.replace(/\\/g, '/');
if (normalized.startsWith('/') || normalized.startsWith('~')) return false;
if (normalized.split('/').some(part => part === '..')) return false;
return true;
}
function isValidDisplayName(value?: string) {
if (value === undefined) return true;
const text = String(value).trim();
return text.length > 0 && text.length <= 200;
}
function normalizeRelativeDir(value: string) {
return value
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.join('/');
}
function resolveOutputRelativeDir(data: any, fallbackName: string) {
const candidates = [
data?.outputRelativeDir,
data?.outputPath,
data?.targetPath,
data?.targetDir,
data?.folderPath,
data?.relativePath,
data?.pagePath,
];
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const normalized = normalizeRelativeDir(candidate.trim());
if (!normalized) continue;
if (!isSafeRelativePath(normalized)) return null;
const segments = normalized.split('/');
if (segments.length === 0 || segments.some(segment => !isSafeName(segment))) {
return null;
}
return normalized;
}
return fallbackName;
}
function resolvePrototypeOutputDir(projectRoot: string, outputRelativeDir: string) {
return path.join(projectRoot, 'src', 'prototypes', ...outputRelativeDir.split('/'));
}
/* ------------------------------------------------------------------ */
/* WebSocket / HTTP helpers */
/* ------------------------------------------------------------------ */
function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
function sendJson(res: MiddlewareResponse, payload: unknown, status = 200): void {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
function parseSocketMessage(rawData: RawData): WebSocketMessage {
const text = typeof rawData === 'string'
? rawData
: Buffer.isBuffer(rawData)
? rawData.toString('utf8')
: Array.isArray(rawData)
? Buffer.concat(rawData).toString('utf8')
: Buffer.from(rawData as ArrayBuffer).toString('utf8');
return JSON.parse(text) as WebSocketMessage;
}
function sendWsMessage(ws: WebSocket, payload: unknown): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function broadcast(clients: Set<WebSocket>, message: WebSocketMessage): number {
const data = JSON.stringify(message);
let count = 0;
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
count += 1;
}
}
return count;
}
/* ------------------------------------------------------------------ */
/* Chrome-export message handlers */
/* ------------------------------------------------------------------ */
function handleChromeExportInit(
ws: WebSocket,
message: WebSocketMessage,
context: HandleMessageContext,
): void {
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
const pageName = String(data.pageName || '').trim();
const displayName = data.displayName !== undefined ? String(data.displayName).trim() : undefined;
const mode = data.mode === 'files' ? 'files' as const : 'zip' as const;
const totalChunks = Number(data.totalChunks);
const totalBytes = typeof data.totalBytes === 'number' ? data.totalBytes : undefined;
const fileNameRaw = String(data.fileName || 'chrome-export.zip');
const fileName = path.basename(fileNameRaw || 'chrome-export.zip');
const outputRelativeDir = resolveOutputRelativeDir(data, pageName);
if (!transferId || !isSafeName(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', message: 'transferId is invalid' });
}
if (!pageName || !isSafeName(pageName)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'pageName is invalid' });
}
if (!isValidDisplayName(displayName)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'displayName is invalid' });
}
if (!outputRelativeDir) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'output path is invalid' });
}
if (mode === 'zip' && (!Number.isFinite(totalChunks) || totalChunks <= 0)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'totalChunks is invalid' });
}
if (context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'transferId already exists' });
}
const transferDir = path.join(context.projectRoot, 'temp', 'chrome-export', transferId);
const filesRoot = mode === 'files' ? path.join(transferDir, 'files') : undefined;
if (filesRoot) {
ensureDir(filesRoot);
}
const session: UploadSession = {
transferId,
pageName,
displayName,
outputRelativeDir,
fileName,
mode,
totalChunks,
totalBytes,
receivedChunks: 0,
receivedBytes: 0,
chunks: new Map(),
filesRoot,
filesReceived: 0,
startedAt: Date.now(),
};
context.uploadSessions.set(transferId, session);
sendWsMessage(ws, { type: 'chrome-export:ack', transferId });
}
function handleChromeExportChunk(
ws: WebSocket,
message: WebSocketMessage,
context: HandleMessageContext,
): void {
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
const chunkIndex = Number(data.index);
const chunkData = data.data;
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
const session = context.uploadSessions.get(transferId)!;
if (session.mode !== 'zip') {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'chunk not allowed for files mode' });
}
if (!Number.isFinite(chunkIndex) || chunkIndex < 0) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid chunk index' });
}
if (typeof chunkData !== 'string' || !chunkData) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid chunk data' });
}
if (chunkIndex >= session.totalChunks) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'chunk index out of range' });
}
if (!session.chunks.has(chunkIndex)) {
const buffer = Buffer.from(chunkData, 'base64');
session.chunks.set(chunkIndex, buffer);
session.receivedChunks = session.chunks.size;
session.receivedBytes += buffer.byteLength;
}
sendWsMessage(ws, {
type: 'chrome-export:progress',
transferId,
receivedChunks: session.receivedChunks,
totalChunks: session.totalChunks,
receivedBytes: session.receivedBytes,
totalBytes: session.totalBytes,
});
}
function handleChromeExportFile(
ws: WebSocket,
message: WebSocketMessage,
context: HandleMessageContext,
): void {
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
const relativePath = String(data.path || data.relativePath || '').trim();
const fileData = data.data;
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
const session = context.uploadSessions.get(transferId)!;
if (session.mode !== 'files') {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'file not allowed for zip mode' });
}
if (!isSafeRelativePath(relativePath)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid file path' });
}
if (typeof fileData !== 'string' || !fileData) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'invalid file data' });
}
if (!session.filesRoot) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'files root not ready' });
}
const targetPath = path.join(session.filesRoot, relativePath);
ensureDir(path.dirname(targetPath));
const buffer = Buffer.from(fileData, 'base64');
fs.writeFileSync(targetPath, buffer);
session.filesReceived += 1;
sendWsMessage(ws, {
type: 'chrome-export:progress',
transferId,
filesReceived: session.filesReceived,
});
}
async function runConverterAndRespond(
ws: WebSocket,
session: UploadSession,
sourceDir: string,
context: HandleMessageContext,
): Promise<void> {
const outputName = session.pageName;
if (!isSafeName(outputName)) {
sendWsMessage(ws, { type: 'chrome-export:error', transferId: session.transferId, message: 'invalid pageName' });
return;
}
const scriptPath = path.join(context.projectRoot, 'scripts', 'chrome-export-converter.mjs');
const commandArgs = [scriptPath, sourceDir, outputName];
if (session.displayName) {
commandArgs.push('--display-name', session.displayName);
}
commandArgs.push('--target-dir', session.outputRelativeDir);
sendWsMessage(ws, { type: 'chrome-export:status', transferId: session.transferId, stage: 'importing' });
const transferDir = path.join(context.projectRoot, 'temp', 'chrome-export', session.transferId);
const runCmd = await getRunCommand();
void runCmd({
command: nodeCommand,
args: commandArgs,
cwd: context.projectRoot,
capture: true,
}).then((result: any) => {
if (result.code !== 0) {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId: session.transferId,
message: result.stderr || result.stdout || 'import failed',
});
} else {
const outputDir = resolvePrototypeOutputDir(context.projectRoot, session.outputRelativeDir);
sendWsMessage(ws, {
type: 'chrome-export:done',
transferId: session.transferId,
pageName: outputName,
displayName: session.displayName,
outputRelativeDir: session.outputRelativeDir,
sourceDir,
outputDir,
stdout: result.stdout ? String(result.stdout).trim() : undefined,
stderr: result.stderr ? String(result.stderr).trim() : undefined,
});
if (fs.existsSync(transferDir)) {
fs.rmSync(transferDir, { recursive: true, force: true });
}
context.uploadSessions.delete(session.transferId);
}
}).catch((error: any) => {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId: session.transferId,
message: error?.message || 'import failed',
});
});
}
function handleChromeExportComplete(
ws: WebSocket,
message: WebSocketMessage,
context: HandleMessageContext,
): void {
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
const session = context.uploadSessions.get(transferId)!;
const inboxRoot = path.join(context.projectRoot, 'temp', 'chrome-export');
const transferDir = path.join(inboxRoot, transferId);
const extractDir = path.join(transferDir, 'extract');
if (session.mode === 'zip') {
const missing: number[] = [];
for (let i = 0; i < session.totalChunks; i += 1) {
if (!session.chunks.has(i)) {
missing.push(i);
}
}
if (missing.length > 0) {
return sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: 'missing chunks',
missing,
});
}
const orderedBuffers: Buffer[] = new Array(session.totalChunks);
for (let i = 0; i < session.totalChunks; i += 1) {
orderedBuffers[i] = session.chunks.get(i)!;
}
const zipBuffer = Buffer.concat(orderedBuffers);
const zipPath = path.join(transferDir, session.fileName);
ensureDir(transferDir);
fs.writeFileSync(zipPath, zipBuffer);
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true });
}
ensureDir(extractDir);
sendWsMessage(ws, { type: 'chrome-export:status', transferId, stage: 'extracting' });
getExtractZip().then((extract) => extract(zipPath, { dir: extractDir }))
.then(() => {
const inferred = inferExtractedRootFolder(extractDir);
if (inferred.entryCount === 0) {
throw new Error('empty zip');
}
const sourceDir = inferred.hasRootFolder
? path.join(extractDir, inferred.rootFolderName)
: extractDir;
runConverterAndRespond(ws, session, sourceDir, context);
})
.catch((error: any) => {
sendWsMessage(ws, {
type: 'chrome-export:error',
transferId,
message: error?.message || 'extract failed',
});
});
} else {
if (!session.filesRoot) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'files root not ready' });
}
runConverterAndRespond(ws, session, session.filesRoot, context);
}
}
function handleChromeExportAbort(
ws: WebSocket,
message: WebSocketMessage,
context: HandleMessageContext,
): void {
const data = (message.data ?? message.payload ?? {}) as any;
const transferId = String(data.transferId || '').trim();
if (!transferId || !context.uploadSessions.has(transferId)) {
return sendWsMessage(ws, { type: 'chrome-export:error', transferId, message: 'unknown transferId' });
}
context.uploadSessions.delete(transferId);
sendWsMessage(ws, { type: 'chrome-export:aborted', transferId });
}
/* ------------------------------------------------------------------ */
/* Main socket message dispatcher */
/* ------------------------------------------------------------------ */
function handleSocketMessage(
ws: WebSocket,
message: WebSocketMessage,
clients: Set<WebSocket>,
context: HandleMessageContext,
): void {
switch (message.type) {
case 'identify': {
const meta = context.clientMeta.get(ws);
if (meta) {
meta.type = message.client || meta.type;
meta.version = message.version || meta.version;
}
sendWsMessage(ws, {
type: 'identified',
message: '身份识别成功',
});
return;
}
case 'ping':
sendWsMessage(ws, { type: 'pong' });
return;
case 'broadcast':
broadcast(clients, {
type: 'broadcast',
data: message.data,
});
return;
case 'echo':
sendWsMessage(ws, {
type: 'echo',
data: message.data,
});
return;
case 'chrome-export:init':
handleChromeExportInit(ws, message, context);
return;
case 'chrome-export:chunk':
handleChromeExportChunk(ws, message, context);
return;
case 'chrome-export:file':
handleChromeExportFile(ws, message, context);
return;
case 'chrome-export:complete':
handleChromeExportComplete(ws, message, context);
return;
case 'chrome-export:abort':
handleChromeExportAbort(ws, message, context);
return;
default:
sendWsMessage(ws, {
type: 'unknown',
message: `未知的消息类型: ${message.type}`,
});
}
}
/* ------------------------------------------------------------------ */
/* Client list / send helpers */
/* ------------------------------------------------------------------ */
function serializeClients(clientMeta: Map<WebSocket, ClientMeta>) {
const clients = Array.from(clientMeta.values()).map((item) => ({
id: item.id,
type: item.type,
version: item.version,
address: item.address,
connectedAt: item.connectedAt,
}));
const stats = clients.reduce<Record<string, number>>((acc, item) => {
acc[item.type] = (acc[item.type] || 0) + 1;
return acc;
}, {});
return {
clients,
stats,
total: clients.length,
};
}
function selectTargetClients(
clients: Set<WebSocket>,
clientMeta: Map<WebSocket, ClientMeta>,
targetClientTypes: string[],
): Set<WebSocket> {
if (targetClientTypes.length === 0) {
return clients;
}
return new Set(Array.from(clients).filter((ws) => {
const meta = clientMeta.get(ws);
return Boolean(meta?.type && targetClientTypes.includes(meta.type));
}));
}
function normalizeTargetClientTypes(body: Record<string, unknown>): string[] {
if (Array.isArray(body.targetClientTypes)) {
return body.targetClientTypes.filter((value): value is string => typeof value === 'string' && value.length > 0);
}
return typeof body.targetClientType === 'string' && body.targetClientType
? [body.targetClientType]
: [];
}
function createRelayMessage(body: Record<string, unknown>, data: unknown): WebSocketMessage {
const message: WebSocketMessage = {
type: body.type as string,
data,
};
if (typeof body.widgetId === 'string') message.widgetId = body.widgetId;
if (typeof body.pageId === 'string') message.pageId = body.pageId;
if (body.payload !== undefined) message.payload = body.payload;
if (body.blurImages !== undefined) message.blurImages = body.blurImages;
if (body.metadata !== undefined) message.metadata = body.metadata;
return message;
}
async function handleSendRequest(
req: IncomingMessage,
res: MiddlewareResponse,
clients: Set<WebSocket>,
clientMeta: Map<WebSocket, ClientMeta>,
): Promise<void> {
if (req.method !== 'POST') {
sendJson(res, { error: 'Method not allowed' }, 405);
return;
}
let body: Record<string, unknown>;
try {
body = JSON.parse(await readRequestBody(req) || '{}') as Record<string, unknown>;
} catch {
sendJson(res, { error: 'invalid json body' }, 400);
return;
}
const type = body.type;
if (!type || typeof type !== 'string') {
sendJson(res, { error: 'type is required' }, 400);
return;
}
if (type === 'sync-widget-content' || type === 'sync-page-content') {
sendJson(res, { error: 'Figma 同步已下线,请使用导出 Make' }, 410);
return;
}
const data = body.payload !== undefined ? body.payload : body.data;
if (data === undefined || data === null) {
sendJson(res, { error: 'data is required' }, 400);
return;
}
const targetClientTypes = normalizeTargetClientTypes(body);
const targetClients = selectTargetClients(clients, clientMeta, targetClientTypes);
const allClientCount = clients.size;
if (allClientCount === 0) {
sendJson(res, { ok: true, sent: 0, warning: 'no clients connected' });
return;
}
sendJson(res, {
ok: true,
sent: targetClients.size,
...(targetClientTypes.length > 0 && targetClients.size === 0
? { warning: 'no target clients connected' }
: {}),
});
setImmediate(() => {
broadcast(targetClients, createRelayMessage(body, data));
});
}
/* ------------------------------------------------------------------ */
/* Plugin entry */
/* ------------------------------------------------------------------ */
export function websocketPlugin(): Plugin {
let wss: WebSocketServer | null = null;
const clients = new Set<WebSocket>();
const clientMeta = new Map<WebSocket, ClientMeta>();
const uploadSessions = new Map<string, UploadSession>();
let nextClientId = 1;
const projectRoot = process.cwd();
return {
name: 'make-project-websocket',
apply: 'serve',
configureServer(server: ViteDevServer) {
wss = new WebSocketServer({ noServer: true });
const handleUpgrade = (req: IncomingMessage, socket: any, head: Buffer) => {
const pathname = req.url ? new URL(req.url, 'http://localhost').pathname : '';
if (pathname !== WS_PATH) {
return;
}
wss?.handleUpgrade(req, socket, head, (ws) => {
wss?.emit('connection', ws, req);
});
};
server.httpServer?.on('upgrade', handleUpgrade);
const messageContext: HandleMessageContext = {
clientMeta,
uploadSessions,
projectRoot,
};
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
const url = new URL(req.url || '/', 'http://localhost');
const meta: ClientMeta = {
id: nextClientId,
type: url.searchParams.get('client') || 'unknown',
version: url.searchParams.get('version') || undefined,
address: req.socket.remoteAddress,
connectedAt: Date.now(),
};
nextClientId += 1;
clients.add(ws);
clientMeta.set(ws, meta);
sendWsMessage(ws, {
type: 'connected',
message: 'WebSocket 连接成功',
});
ws.on('message', (rawData) => {
try {
handleSocketMessage(ws, parseSocketMessage(rawData), clients, messageContext);
} catch {
sendWsMessage(ws, {
type: 'error',
message: '消息格式错误',
});
}
});
const cleanup = () => {
clients.delete(ws);
clientMeta.delete(ws);
};
ws.on('close', cleanup);
ws.on('error', cleanup);
});
server.middlewares.use('/api/ws/clients', (req, res) => {
if (req.method !== 'GET') {
sendJson(res, { error: 'Method not allowed' }, 405);
return;
}
sendJson(res, serializeClients(clientMeta));
});
server.middlewares.use('/api/ws/send', (req, res) => {
void handleSendRequest(req, res, clients, clientMeta);
});
server.httpServer?.on('close', () => {
wss?.close();
clients.clear();
clientMeta.clear();
server.httpServer?.off?.('upgrade', handleUpgrade);
});
},
};
}

View File

@@ -0,0 +1,112 @@
import type { Plugin } from 'vite';
import fs from 'fs';
import path from 'path';
import { writeServerInfo } from '../scripts/utils/serverInfo.mjs';
import { getLocalIP } from './utils/httpUtils';
import {
MAKE_CONFIG_RELATIVE_PATH,
} from './utils/makeConstants';
import { syncMakeProjectMetadata } from '../scripts/sync-project-metadata.mjs';
type DevServerInfo = {
pid: number;
port: number;
host: string;
origin: string;
projectRoot: string;
startedAt: string;
localIP: string;
timestamp: string;
};
const SERVER_INFO_HEARTBEAT_INTERVAL_MS = 5_000;
function resolveDevServerInfo(server: any, startedAt: string): DevServerInfo {
const localIP = getLocalIP();
const actualPort = server.httpServer?.address()?.port || server.config.server?.port || 5173;
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'));
displayHost = config.server?.host || 'localhost';
} catch {
// Ignore config parse errors and keep default.
}
}
const timestamp = new Date().toISOString();
return {
pid: process.pid,
port: actualPort,
host: displayHost,
origin: `http://${displayHost}:${actualPort}`,
projectRoot: path.resolve(process.cwd()),
startedAt,
localIP,
timestamp,
};
}
function sendHealth(res: any, 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 writeCurrentDevServerInfo(server: any, startedAt: string): DevServerInfo {
const devServerInfo = resolveDevServerInfo(server, startedAt);
writeServerInfo(process.cwd(), 'runtime', devServerInfo);
return devServerInfo;
}
export function writeDevServerInfoPlugin(): Plugin {
return {
name: 'write-dev-server-info',
configureServer(server: any) {
const startedAt = new Date().toISOString();
server.middlewares.use('/api/health', (req: any, res: any, next: () => void) => {
if (req.method !== 'GET') {
next();
return;
}
const devServerInfo = resolveDevServerInfo(server, startedAt);
sendHealth(res, {
ok: true,
role: 'runtime',
projectRoot: devServerInfo.projectRoot,
server: devServerInfo,
});
});
server.httpServer?.once('listening', () => {
try {
const devServerInfo = writeCurrentDevServerInfo(server, startedAt);
syncMakeProjectMetadata(process.cwd());
const heartbeat = setInterval(() => {
try {
writeCurrentDevServerInfo(server, startedAt);
} catch (error) {
console.error('Failed to refresh dev server info:', error);
}
}, SERVER_INFO_HEARTBEAT_INTERVAL_MS);
heartbeat.unref?.();
server.httpServer?.once('close', () => {
clearInterval(heartbeat);
});
console.log(`\n✅ Dev server info written to .axhub/make/.dev-server-info.json`);
console.log(`✅ Axhub Make Project metadata synced`);
console.log(` Local: ${devServerInfo.origin}`);
console.log(` Network: http://${devServerInfo.localIP}:${devServerInfo.port}\n`);
} catch (error) {
console.error('Failed to write dev server info:', error);
}
});
},
};
}

39
vite-plugins/ws.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
declare module 'ws' {
import type { IncomingMessage } from 'node:http';
export type RawData = Buffer | ArrayBuffer | Buffer[];
export class WebSocket {
static readonly CONNECTING: number;
static readonly OPEN: number;
static readonly CLOSING: number;
static readonly CLOSED: number;
readonly readyState: number;
constructor(address: string);
send(data: string): void;
close(): void;
on(event: 'message', listener: (data: RawData) => void): this;
on(event: 'close' | 'error' | 'open', listener: (...args: any[]) => void): this;
once(event: 'message', listener: (data: RawData) => void): this;
once(event: 'close' | 'error' | 'open', listener: (...args: any[]) => void): this;
off(event: 'message', listener: (data: RawData) => void): this;
off(event: 'close' | 'error' | 'open', listener: (...args: any[]) => void): this;
}
export class WebSocketServer {
constructor(options: { noServer: true });
on(event: 'connection', listener: (ws: WebSocket, req: IncomingMessage) => void): this;
emit(event: 'connection', ws: WebSocket, req: IncomingMessage): boolean;
handleUpgrade(
req: IncomingMessage,
socket: unknown,
head: Buffer,
callback: (ws: WebSocket) => void,
): void;
close(): void;
}
}