初始化项目版本
This commit is contained in:
153
vite-plugins/annotationRuntimeOptimizeDeps.test.ts
Normal file
153
vite-plugins/annotationRuntimeOptimizeDeps.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { ConfigEnv, Plugin, UserConfig } from 'vite';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
annotationRuntimeOptimizeDepsPlugin,
|
||||
applyAnnotationRuntimeOptimizeDepsSignature,
|
||||
createAnnotationRuntimeOptimizeDepsSignature,
|
||||
resolveLocalAnnotationRuntimeAlias,
|
||||
resolveLocalAnnotationRuntimeWorkspaceRoot,
|
||||
} from './annotationRuntimeOptimizeDeps';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function runConfigHook(plugin: Plugin, config: UserConfig, env: ConfigEnv): Promise<UserConfig> {
|
||||
const hook = plugin.config;
|
||||
if (typeof hook === 'function') {
|
||||
return await hook.call(undefined, config, env) as UserConfig;
|
||||
}
|
||||
return await hook?.handler.call(undefined, config, env) as UserConfig;
|
||||
}
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
function createFixtureProject(entryContent: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'annotation-runtime-optimize-deps-'));
|
||||
tempRoots.push(root);
|
||||
writeFile(path.join(root, 'package.json'), '{"type":"module"}\n');
|
||||
writeFile(path.join(root, 'node_modules/@axhub/annotation/package.json'), JSON.stringify({
|
||||
name: '@axhub/annotation',
|
||||
version: '1.0.9',
|
||||
module: './dist/index.mjs',
|
||||
}, null, 2));
|
||||
writeFile(path.join(root, 'node_modules/@axhub/annotation/dist/index.mjs'), entryContent);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('annotation runtime optimizeDeps signature', () => {
|
||||
it('changes when the installed annotation runtime entry changes', () => {
|
||||
const root = createFixtureProject('export const runtimeVersion = 1;\n');
|
||||
const requireFromProject = createRequire(path.join(root, 'package.json'));
|
||||
const firstSignature = createAnnotationRuntimeOptimizeDepsSignature(root, requireFromProject);
|
||||
|
||||
writeFile(
|
||||
path.join(root, 'node_modules/@axhub/annotation/dist/index.mjs'),
|
||||
'export const runtimeVersion = 2;\n',
|
||||
);
|
||||
const nextSignature = createAnnotationRuntimeOptimizeDepsSignature(root, requireFromProject);
|
||||
|
||||
expect(firstSignature).toContain('1.0.9');
|
||||
expect(nextSignature).toContain('1.0.9');
|
||||
expect(nextSignature).not.toBe(firstSignature);
|
||||
});
|
||||
|
||||
it('preserves existing optimizeDeps esbuild options when applying the signature define', () => {
|
||||
const config = applyAnnotationRuntimeOptimizeDepsSignature({
|
||||
optimizeDeps: {
|
||||
include: ['lucide-react'],
|
||||
esbuildOptions: {
|
||||
define: {
|
||||
__EXISTING_DEFINE__: '"kept"',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, 'annotation-signature');
|
||||
|
||||
expect(config.optimizeDeps?.include).toEqual(['lucide-react']);
|
||||
expect(config.optimizeDeps?.esbuildOptions?.define).toMatchObject({
|
||||
__EXISTING_DEFINE__: '"kept"',
|
||||
__AXHUB_ANNOTATION_OPTIMIZE_DEPS_SIGNATURE__: '"annotation-signature"',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the workspace annotation runtime build for local monorepo development only', () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'annotation-runtime-workspace-'));
|
||||
tempRoots.push(workspaceRoot);
|
||||
const projectRoot = path.join(workspaceRoot, 'apps/axhub-make/client');
|
||||
const localEntry = path.join(workspaceRoot, 'packages/axhub-annotation/dist/index.mjs');
|
||||
|
||||
writeFile(path.join(workspaceRoot, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n - 'packages/*'\n");
|
||||
writeFile(path.join(projectRoot, 'package.json'), '{"type":"module"}\n');
|
||||
writeFile(localEntry, 'export const localAnnotationRuntime = true;\n');
|
||||
|
||||
expect(resolveLocalAnnotationRuntimeAlias(projectRoot)).toBe(localEntry);
|
||||
|
||||
fs.rmSync(path.join(workspaceRoot, 'packages/axhub-annotation/dist'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(resolveLocalAnnotationRuntimeAlias(projectRoot)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips nested workspace roots until it finds the monorepo annotation runtime build', () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'annotation-runtime-nested-workspace-'));
|
||||
tempRoots.push(workspaceRoot);
|
||||
const nestedMakeRoot = path.join(workspaceRoot, 'apps/axhub-make');
|
||||
const projectRoot = path.join(nestedMakeRoot, 'client');
|
||||
const localEntry = path.join(workspaceRoot, 'packages/axhub-annotation/dist/index.mjs');
|
||||
|
||||
writeFile(path.join(workspaceRoot, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n - 'packages/*'\n");
|
||||
writeFile(path.join(nestedMakeRoot, 'pnpm-workspace.yaml'), "packages:\n - 'client'\n");
|
||||
writeFile(path.join(projectRoot, 'package.json'), '{"type":"module"}\n');
|
||||
writeFile(localEntry, 'export const localAnnotationRuntime = true;\n');
|
||||
|
||||
expect(resolveLocalAnnotationRuntimeAlias(projectRoot)).toBe(localEntry);
|
||||
expect(resolveLocalAnnotationRuntimeWorkspaceRoot(projectRoot)).toBe(workspaceRoot);
|
||||
});
|
||||
|
||||
it('allows Vite dev server to serve the local monorepo annotation runtime build', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'annotation-runtime-fs-allow-'));
|
||||
tempRoots.push(workspaceRoot);
|
||||
const nestedMakeRoot = path.join(workspaceRoot, 'apps/axhub-make');
|
||||
const projectRoot = path.join(nestedMakeRoot, 'client');
|
||||
const localEntry = path.join(workspaceRoot, 'packages/axhub-annotation/dist/index.mjs');
|
||||
|
||||
writeFile(path.join(workspaceRoot, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n - 'packages/*'\n");
|
||||
writeFile(path.join(nestedMakeRoot, 'pnpm-workspace.yaml'), "packages:\n - 'client'\n");
|
||||
writeFile(path.join(projectRoot, 'package.json'), '{"type":"module"}\n');
|
||||
writeFile(localEntry, 'export const localAnnotationRuntime = true;\n');
|
||||
|
||||
const plugin = annotationRuntimeOptimizeDepsPlugin(projectRoot);
|
||||
const nextConfig = await runConfigHook(plugin, {
|
||||
server: {
|
||||
fs: {
|
||||
allow: [nestedMakeRoot],
|
||||
},
|
||||
},
|
||||
}, { command: 'serve', mode: 'development' });
|
||||
|
||||
expect(nextConfig.resolve?.alias).toEqual([
|
||||
{
|
||||
find: '@axhub/annotation',
|
||||
replacement: localEntry,
|
||||
},
|
||||
]);
|
||||
expect(nextConfig.server?.fs?.allow).toContain(nestedMakeRoot);
|
||||
expect(nextConfig.server?.fs?.allow).toContain(workspaceRoot);
|
||||
});
|
||||
});
|
||||
208
vite-plugins/annotationRuntimeOptimizeDeps.ts
Normal file
208
vite-plugins/annotationRuntimeOptimizeDeps.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { searchForWorkspaceRoot } from 'vite';
|
||||
import type { Plugin, UserConfig } from 'vite';
|
||||
|
||||
const ANNOTATION_PACKAGE_NAME = '@axhub/annotation';
|
||||
const ANNOTATION_SIGNATURE_DEFINE = '__AXHUB_ANNOTATION_OPTIMIZE_DEPS_SIGNATURE__';
|
||||
|
||||
type PackageJson = {
|
||||
version?: unknown;
|
||||
module?: unknown;
|
||||
main?: unknown;
|
||||
};
|
||||
|
||||
function findWorkspaceRoots(startDir: string): string[] {
|
||||
let current = path.resolve(startDir);
|
||||
const roots: string[] = [];
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
|
||||
roots.push(current);
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return roots;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function readFileIfExists(filePath: string): string {
|
||||
try {
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackageRoot(
|
||||
packageName: string,
|
||||
projectRoot: string,
|
||||
requireFromProject = createRequire(path.join(projectRoot, 'package.json')),
|
||||
): string | null {
|
||||
try {
|
||||
return path.dirname(requireFromProject.resolve(`${packageName}/package.json`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLocalAnnotationRuntimeAlias(projectRoot = process.cwd()): string | null {
|
||||
for (const workspaceRoot of findWorkspaceRoots(projectRoot)) {
|
||||
const localEntry = path.join(workspaceRoot, 'packages/axhub-annotation/dist/index.mjs');
|
||||
if (fs.existsSync(localEntry)) {
|
||||
return localEntry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLocalAnnotationRuntimeWorkspaceRoot(projectRoot = process.cwd()): string | null {
|
||||
const localEntry = resolveLocalAnnotationRuntimeAlias(projectRoot);
|
||||
return localEntry ? path.dirname(path.dirname(path.dirname(path.dirname(localEntry)))) : null;
|
||||
}
|
||||
|
||||
function resolveAnnotationRuntimeEntry(
|
||||
projectRoot: string,
|
||||
requireFromProject?: NodeRequire,
|
||||
): {
|
||||
packageRoot: string;
|
||||
rawPackageJson: string;
|
||||
packageJson: PackageJson;
|
||||
entry: string;
|
||||
entryPath: string;
|
||||
} | null {
|
||||
const localEntry = resolveLocalAnnotationRuntimeAlias(projectRoot);
|
||||
if (localEntry) {
|
||||
const packageRoot = path.dirname(path.dirname(localEntry));
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json');
|
||||
const rawPackageJson = readFileIfExists(packageJsonPath);
|
||||
let packageJson: PackageJson = {};
|
||||
try {
|
||||
packageJson = rawPackageJson ? JSON.parse(rawPackageJson) as PackageJson : {};
|
||||
} catch {
|
||||
packageJson = {};
|
||||
}
|
||||
return {
|
||||
packageRoot,
|
||||
rawPackageJson,
|
||||
packageJson,
|
||||
entry: path.relative(packageRoot, localEntry),
|
||||
entryPath: localEntry,
|
||||
};
|
||||
}
|
||||
|
||||
const packageRoot = resolvePackageRoot(ANNOTATION_PACKAGE_NAME, projectRoot, requireFromProject);
|
||||
if (!packageRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json');
|
||||
const rawPackageJson = readFileIfExists(packageJsonPath);
|
||||
let packageJson: PackageJson = {};
|
||||
try {
|
||||
packageJson = rawPackageJson ? JSON.parse(rawPackageJson) as PackageJson : {};
|
||||
} catch {
|
||||
packageJson = {};
|
||||
}
|
||||
|
||||
const entry = typeof packageJson.module === 'string'
|
||||
? packageJson.module
|
||||
: typeof packageJson.main === 'string'
|
||||
? packageJson.main
|
||||
: 'dist/index.mjs';
|
||||
const entryPath = path.resolve(packageRoot, entry);
|
||||
|
||||
return {
|
||||
packageRoot,
|
||||
rawPackageJson,
|
||||
packageJson,
|
||||
entry,
|
||||
entryPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnnotationRuntimeOptimizeDepsSignature(
|
||||
projectRoot = process.cwd(),
|
||||
requireFromProject?: NodeRequire,
|
||||
): string {
|
||||
const runtimeEntry = resolveAnnotationRuntimeEntry(projectRoot, requireFromProject);
|
||||
if (!runtimeEntry) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
const entryContent = readFileIfExists(runtimeEntry.entryPath);
|
||||
|
||||
return [
|
||||
runtimeEntry.packageRoot,
|
||||
String(runtimeEntry.packageJson.version || ''),
|
||||
runtimeEntry.entry,
|
||||
shortHash(`${runtimeEntry.rawPackageJson}\n${entryContent}`),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function applyAnnotationRuntimeOptimizeDepsSignature(
|
||||
config: UserConfig,
|
||||
signature: string,
|
||||
): UserConfig {
|
||||
return {
|
||||
...config,
|
||||
optimizeDeps: {
|
||||
...config.optimizeDeps,
|
||||
esbuildOptions: {
|
||||
...config.optimizeDeps?.esbuildOptions,
|
||||
define: {
|
||||
...config.optimizeDeps?.esbuildOptions?.define,
|
||||
[ANNOTATION_SIGNATURE_DEFINE]: JSON.stringify(signature),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function annotationRuntimeOptimizeDepsPlugin(projectRoot = process.cwd()): Plugin {
|
||||
return {
|
||||
name: 'annotation-runtime-optimize-deps-signature',
|
||||
config(config) {
|
||||
const nextConfig = applyAnnotationRuntimeOptimizeDepsSignature(
|
||||
config,
|
||||
createAnnotationRuntimeOptimizeDepsSignature(projectRoot),
|
||||
);
|
||||
const localAnnotationAlias = resolveLocalAnnotationRuntimeAlias(projectRoot);
|
||||
if (!localAnnotationAlias) {
|
||||
return nextConfig;
|
||||
}
|
||||
const localAnnotationWorkspaceRoot = resolveLocalAnnotationRuntimeWorkspaceRoot(projectRoot);
|
||||
const existingAllow = nextConfig.server?.fs?.allow;
|
||||
const serverFsAllow = [
|
||||
...(Array.isArray(existingAllow) ? existingAllow : [searchForWorkspaceRoot(projectRoot)]),
|
||||
...(localAnnotationWorkspaceRoot ? [localAnnotationWorkspaceRoot] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
...nextConfig,
|
||||
resolve: {
|
||||
...nextConfig.resolve,
|
||||
alias: [
|
||||
{
|
||||
find: ANNOTATION_PACKAGE_NAME,
|
||||
replacement: localAnnotationAlias,
|
||||
},
|
||||
...(Array.isArray(nextConfig.resolve?.alias) ? nextConfig.resolve.alias : []),
|
||||
],
|
||||
},
|
||||
server: {
|
||||
...nextConfig.server,
|
||||
fs: {
|
||||
...nextConfig.server?.fs,
|
||||
allow: Array.from(new Set(serverFsAllow)),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
235
vite-plugins/annotationSourceMarkdown.test.ts
Normal file
235
vite-plugins/annotationSourceMarkdown.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAnnotationSourceMarkdownPlugin,
|
||||
preprocessAnnotationSourceMarkdown,
|
||||
resolveAnnotationMarkdownPath,
|
||||
} from './annotationSourceMarkdown';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function createProjectRoot(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'annotation-source-markdown-'));
|
||||
tempRoots.push(root);
|
||||
fs.mkdirSync(path.join(root, 'src/prototypes/order-review/docs/nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'src/prototypes/order-review/index.tsx'), 'export default function Demo() { return null; }\n', 'utf8');
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeSource(projectRoot: string, source: unknown): string {
|
||||
const sourcePath = path.join(projectRoot, 'src/prototypes/order-review/annotation-source.json');
|
||||
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||
fs.writeFileSync(sourcePath, `${JSON.stringify(source, null, 2)}\n`, 'utf8');
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('annotation source markdown preprocessing', () => {
|
||||
it('inlines directory markdownPath content without creating Make-specific edit URLs', () => {
|
||||
const projectRoot = createProjectRoot();
|
||||
const mdPath = path.join(projectRoot, 'src/prototypes/order-review/docs/prd-03-status.md');
|
||||
fs.writeFileSync(mdPath, '# PRD 03\n\n状态说明', 'utf8');
|
||||
const sourcePath = writeSource(projectRoot, {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: { version: 2, prototypeName: 'order-review', pageId: 'order-review', nodes: [], updatedAt: 1 },
|
||||
markdownMap: {},
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'prd-03-status',
|
||||
title: 'PRD 03 | 状态',
|
||||
markdownPath: 'docs/prd-03-status.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = preprocessAnnotationSourceMarkdown({
|
||||
projectRoot,
|
||||
sourceFilePath: sourcePath,
|
||||
source: JSON.parse(fs.readFileSync(sourcePath, 'utf8')),
|
||||
mode: 'serve',
|
||||
});
|
||||
|
||||
const node = result.source.directory.nodes[0];
|
||||
expect(node).toMatchObject({
|
||||
type: 'markdown',
|
||||
markdown: '# PRD 03\n\n状态说明',
|
||||
markdownPath: 'docs/prd-03-status.md',
|
||||
});
|
||||
expect(node).not.toHaveProperty('markdownEditUrl');
|
||||
expect(result.watchFiles).toEqual([mdPath]);
|
||||
});
|
||||
|
||||
it('does not expose edit URLs when preprocessing for export builds', () => {
|
||||
const projectRoot = createProjectRoot();
|
||||
fs.writeFileSync(path.join(projectRoot, 'src/prototypes/order-review/docs/prd.md'), '# Exported PRD', 'utf8');
|
||||
const sourcePath = writeSource(projectRoot, {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: { version: 2, prototypeName: 'order-review', pageId: 'order-review', nodes: [], updatedAt: 1 },
|
||||
markdownMap: {},
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [{ type: 'markdown', id: 'prd', title: 'PRD', markdownPath: 'docs/prd.md' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = preprocessAnnotationSourceMarkdown({
|
||||
projectRoot,
|
||||
sourceFilePath: sourcePath,
|
||||
source: JSON.parse(fs.readFileSync(sourcePath, 'utf8')),
|
||||
mode: 'build',
|
||||
});
|
||||
|
||||
expect(result.source.directory.nodes[0]).toMatchObject({
|
||||
markdown: '# Exported PRD',
|
||||
markdownPath: 'docs/prd.md',
|
||||
});
|
||||
expect(result.source.directory.nodes[0]).not.toHaveProperty('markdownEditUrl');
|
||||
});
|
||||
|
||||
it('recursively inlines markdownPath documents nested in directory folders', () => {
|
||||
const projectRoot = createProjectRoot();
|
||||
fs.writeFileSync(path.join(projectRoot, 'src/prototypes/order-review/docs/nested/prd.md'), '# Nested PRD', 'utf8');
|
||||
const sourcePath = writeSource(projectRoot, {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: { version: 2, prototypeName: 'order-review', pageId: 'order-review', nodes: [], updatedAt: 1 },
|
||||
markdownMap: {},
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'docs',
|
||||
title: '文档',
|
||||
children: [
|
||||
{ type: 'markdown', id: 'nested-prd', title: 'Nested PRD', markdownPath: 'docs/nested/prd.md' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = preprocessAnnotationSourceMarkdown({
|
||||
projectRoot,
|
||||
sourceFilePath: sourcePath,
|
||||
source: JSON.parse(fs.readFileSync(sourcePath, 'utf8')),
|
||||
mode: 'serve',
|
||||
});
|
||||
|
||||
expect(result.source.directory.nodes[0].children[0]).toMatchObject({
|
||||
type: 'markdown',
|
||||
markdownPath: 'docs/nested/prd.md',
|
||||
markdown: '# Nested PRD',
|
||||
});
|
||||
expect(result.source.directory.nodes[0].children[0]).not.toHaveProperty('markdownEditUrl');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['docs/prd.md', 'src/prototypes/order-review/docs/prd.md'],
|
||||
['docs/nested/prd.md', 'src/prototypes/order-review/docs/nested/prd.md'],
|
||||
])('allows safe prototype-relative markdownPath %s', (markdownPath, expectedProjectRelativePath) => {
|
||||
const projectRoot = createProjectRoot();
|
||||
const sourcePath = path.join(projectRoot, 'src/prototypes/order-review/annotation-source.json');
|
||||
|
||||
const resolved = resolveAnnotationMarkdownPath(projectRoot, sourcePath, markdownPath);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
ok: true,
|
||||
projectRelativePath: expectedProjectRelativePath,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/abs.md',
|
||||
'../secret.md',
|
||||
'docs/../../secret.md',
|
||||
'docs//prd.md',
|
||||
'docs/%2e%2e/secret.md',
|
||||
])('rejects unsafe markdownPath %s', (markdownPath) => {
|
||||
const projectRoot = createProjectRoot();
|
||||
const sourcePath = path.join(projectRoot, 'src/prototypes/order-review/annotation-source.json');
|
||||
|
||||
const resolved = resolveAnnotationMarkdownPath(projectRoot, sourcePath, markdownPath);
|
||||
|
||||
expect(resolved.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to existing inline markdown when the referenced file is missing and warns in dev', () => {
|
||||
const projectRoot = createProjectRoot();
|
||||
const sourcePath = writeSource(projectRoot, {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: { version: 2, prototypeName: 'order-review', pageId: 'order-review', nodes: [], updatedAt: 1 },
|
||||
markdownMap: {},
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [{ type: 'markdown', id: 'prd', title: 'PRD', markdownPath: 'docs/missing.md', markdown: '旧内容' }],
|
||||
},
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
const result = preprocessAnnotationSourceMarkdown({
|
||||
projectRoot,
|
||||
sourceFilePath: sourcePath,
|
||||
source: JSON.parse(fs.readFileSync(sourcePath, 'utf8')),
|
||||
mode: 'serve',
|
||||
});
|
||||
|
||||
expect(result.source.directory.nodes[0]).toMatchObject({
|
||||
markdown: '旧内容',
|
||||
markdownPath: 'docs/missing.md',
|
||||
});
|
||||
expect(result.source.directory.nodes[0]).not.toHaveProperty('markdownEditUrl');
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('docs/missing.md'));
|
||||
});
|
||||
|
||||
it('loads annotation-source.json as an inlined JavaScript module in Vite', async () => {
|
||||
const projectRoot = createProjectRoot();
|
||||
fs.writeFileSync(path.join(projectRoot, 'src/prototypes/order-review/docs/prd.md'), '# Vite PRD', 'utf8');
|
||||
const sourcePath = writeSource(projectRoot, {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: { version: 2, prototypeName: 'order-review', pageId: 'order-review', nodes: [], updatedAt: 1 },
|
||||
markdownMap: {},
|
||||
assetMap: {},
|
||||
directory: {
|
||||
nodes: [{ type: 'markdown', id: 'prd', title: 'PRD', markdownPath: 'docs/prd.md' }],
|
||||
},
|
||||
});
|
||||
const watched: string[] = [];
|
||||
const plugin = createAnnotationSourceMarkdownPlugin(projectRoot, { mode: 'serve' });
|
||||
|
||||
const transform = plugin.transform as (
|
||||
this: { addWatchFile: (file: string) => void },
|
||||
code: string,
|
||||
id: string,
|
||||
) => string | Promise<string> | null;
|
||||
const code = await transform.call(
|
||||
{ addWatchFile: (file: string) => watched.push(file) },
|
||||
fs.readFileSync(sourcePath, 'utf8'),
|
||||
sourcePath,
|
||||
);
|
||||
|
||||
expect(() => JSON.parse(String(code))).not.toThrow();
|
||||
expect(code).not.toContain('export default');
|
||||
expect(code).toContain('# Vite PRD');
|
||||
expect(code).not.toContain('markdownEditUrl');
|
||||
expect(watched).toEqual([path.join(projectRoot, 'src/prototypes/order-review/docs/prd.md')]);
|
||||
});
|
||||
});
|
||||
267
vite-plugins/annotationSourceMarkdown.ts
Normal file
267
vite-plugins/annotationSourceMarkdown.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
type PreprocessMode = 'serve' | 'build';
|
||||
|
||||
type AnnotationSourceMarkdownNode = {
|
||||
type?: unknown;
|
||||
markdownPath?: unknown;
|
||||
markdown?: unknown;
|
||||
children?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type AnnotationSourceLike = {
|
||||
directory?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ResolvedAnnotationMarkdownPath =
|
||||
| {
|
||||
ok: true;
|
||||
absolutePath: string;
|
||||
projectRelativePath: string;
|
||||
prototypeDir: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export interface AnnotationSourceMarkdownPreprocessResult<T extends AnnotationSourceLike = AnnotationSourceLike> {
|
||||
source: T;
|
||||
watchFiles: string[];
|
||||
}
|
||||
|
||||
export interface AnnotationSourceMarkdownPreprocessOptions<T extends AnnotationSourceLike = AnnotationSourceLike> {
|
||||
projectRoot: string;
|
||||
sourceFilePath: string;
|
||||
source: T;
|
||||
mode?: PreprocessMode;
|
||||
}
|
||||
|
||||
function createProjectRelativePath(projectRoot: string, absolutePath: string): string {
|
||||
return path.relative(projectRoot, absolutePath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function isPathInside(parentPath: string, childPath: string): boolean {
|
||||
const relativePath = path.relative(parentPath, childPath);
|
||||
return relativePath === '' || (
|
||||
!relativePath.startsWith('..')
|
||||
&& !path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodePath(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsAbsolutePath(value: string): boolean {
|
||||
return /^[a-z]:[\\/]/iu.test(value);
|
||||
}
|
||||
|
||||
function getPrototypeDirFromAnnotationSourcePath(
|
||||
projectRoot: string,
|
||||
sourceFilePath: string,
|
||||
): string | null {
|
||||
const resolvedProjectRoot = path.resolve(projectRoot);
|
||||
const resolvedSourcePath = path.resolve(sourceFilePath);
|
||||
const relativeSourcePath = createProjectRelativePath(resolvedProjectRoot, resolvedSourcePath);
|
||||
const parts = relativeSourcePath.split('/');
|
||||
if (
|
||||
parts.length !== 4
|
||||
|| parts[0] !== 'src'
|
||||
|| parts[1] !== 'prototypes'
|
||||
|| !parts[2]
|
||||
|| parts[2].startsWith('.')
|
||||
|| parts[2].includes('\0')
|
||||
|| parts[3] !== 'annotation-source.json'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const prototypeDir = path.resolve(resolvedProjectRoot, 'src', 'prototypes', parts[2]);
|
||||
return isPathInside(resolvedProjectRoot, prototypeDir) ? prototypeDir : null;
|
||||
}
|
||||
|
||||
export function resolveAnnotationMarkdownPath(
|
||||
projectRoot: string,
|
||||
sourceFilePath: string,
|
||||
markdownPath: unknown,
|
||||
): ResolvedAnnotationMarkdownPath {
|
||||
const prototypeDir = getPrototypeDirFromAnnotationSourcePath(projectRoot, sourceFilePath);
|
||||
if (!prototypeDir) {
|
||||
return { ok: false, reason: 'annotation-source.json is not under src/prototypes/<id>' };
|
||||
}
|
||||
|
||||
const rawPath = String(markdownPath ?? '').trim();
|
||||
const decodedPath = safeDecodePath(rawPath);
|
||||
if (!rawPath || !decodedPath) {
|
||||
return { ok: false, reason: 'markdownPath is empty or malformed' };
|
||||
}
|
||||
if (
|
||||
rawPath.includes('\0')
|
||||
|| decodedPath.includes('\0')
|
||||
|| path.isAbsolute(rawPath)
|
||||
|| path.isAbsolute(decodedPath)
|
||||
|| isWindowsAbsolutePath(rawPath)
|
||||
|| isWindowsAbsolutePath(decodedPath)
|
||||
|| rawPath.includes('\\')
|
||||
|| decodedPath.includes('\\')
|
||||
) {
|
||||
return { ok: false, reason: 'markdownPath must be a relative POSIX path' };
|
||||
}
|
||||
|
||||
const rawSegments = rawPath.split('/');
|
||||
const decodedSegments = decodedPath.split('/');
|
||||
const hasUnsafeSegment = [...rawSegments, ...decodedSegments].some((segment) => (
|
||||
segment === ''
|
||||
|| segment === '.'
|
||||
|| segment === '..'
|
||||
));
|
||||
if (hasUnsafeSegment) {
|
||||
return { ok: false, reason: 'markdownPath contains unsafe path segments' };
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(prototypeDir, decodedPath);
|
||||
const resolvedProjectRoot = path.resolve(projectRoot);
|
||||
if (!isPathInside(prototypeDir, absolutePath) || !isPathInside(resolvedProjectRoot, absolutePath)) {
|
||||
return { ok: false, reason: 'markdownPath escapes the prototype directory' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
absolutePath,
|
||||
projectRelativePath: createProjectRelativePath(resolvedProjectRoot, absolutePath),
|
||||
prototypeDir,
|
||||
};
|
||||
}
|
||||
|
||||
function preprocessDirectoryNode(
|
||||
node: unknown,
|
||||
options: {
|
||||
projectRoot: string;
|
||||
sourceFilePath: string;
|
||||
mode: PreprocessMode;
|
||||
watchFiles: Set<string>;
|
||||
},
|
||||
): unknown {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const record = node as AnnotationSourceMarkdownNode;
|
||||
if (record.type === 'folder') {
|
||||
return {
|
||||
...record,
|
||||
children: Array.isArray(record.children)
|
||||
? record.children.map((child) => preprocessDirectoryNode(child, options))
|
||||
: record.children,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.type !== 'markdown') {
|
||||
return record;
|
||||
}
|
||||
|
||||
const nextNode: AnnotationSourceMarkdownNode = { ...record };
|
||||
delete nextNode.markdownEditUrl;
|
||||
|
||||
if (typeof record.markdownPath !== 'string' || !record.markdownPath.trim()) {
|
||||
return nextNode;
|
||||
}
|
||||
|
||||
const resolved = resolveAnnotationMarkdownPath(
|
||||
options.projectRoot,
|
||||
options.sourceFilePath,
|
||||
record.markdownPath,
|
||||
);
|
||||
if (resolved.ok === false) {
|
||||
if (options.mode === 'serve') {
|
||||
console.warn(`[annotation-source] Ignored unsafe markdownPath "${record.markdownPath}": ${resolved.reason}`);
|
||||
}
|
||||
return nextNode;
|
||||
}
|
||||
|
||||
options.watchFiles.add(resolved.absolutePath);
|
||||
if (fs.existsSync(resolved.absolutePath)) {
|
||||
nextNode.markdown = fs.readFileSync(resolved.absolutePath, 'utf8');
|
||||
} else if (options.mode === 'serve') {
|
||||
console.warn(`[annotation-source] Markdown file not found for markdownPath "${record.markdownPath}": ${resolved.projectRelativePath}`);
|
||||
}
|
||||
|
||||
return nextNode;
|
||||
}
|
||||
|
||||
export function preprocessAnnotationSourceMarkdown<T extends AnnotationSourceLike>(
|
||||
options: AnnotationSourceMarkdownPreprocessOptions<T>,
|
||||
): AnnotationSourceMarkdownPreprocessResult<T> {
|
||||
const mode = options.mode ?? 'build';
|
||||
const source = options.source;
|
||||
const directoryRecord = source?.directory && typeof source.directory === 'object' && !Array.isArray(source.directory)
|
||||
? source.directory as Record<string, unknown>
|
||||
: null;
|
||||
const nodes = directoryRecord?.nodes;
|
||||
if (!Array.isArray(nodes) || !getPrototypeDirFromAnnotationSourcePath(options.projectRoot, options.sourceFilePath)) {
|
||||
return { source, watchFiles: [] };
|
||||
}
|
||||
|
||||
const watchFiles = new Set<string>();
|
||||
const nextSource = {
|
||||
...source,
|
||||
directory: {
|
||||
...directoryRecord,
|
||||
nodes: nodes.map((node) => preprocessDirectoryNode(node, {
|
||||
projectRoot: options.projectRoot,
|
||||
sourceFilePath: options.sourceFilePath,
|
||||
mode,
|
||||
watchFiles,
|
||||
})),
|
||||
},
|
||||
} as T;
|
||||
|
||||
return {
|
||||
source: nextSource,
|
||||
watchFiles: Array.from(watchFiles),
|
||||
};
|
||||
}
|
||||
|
||||
function cleanModuleId(id: string): string {
|
||||
return id.split(/[?#]/u)[0] || id;
|
||||
}
|
||||
|
||||
export function createAnnotationSourceMarkdownPlugin(
|
||||
projectRoot = process.cwd(),
|
||||
options: { mode?: PreprocessMode } = {},
|
||||
): Plugin {
|
||||
const mode = options.mode ?? 'build';
|
||||
return {
|
||||
name: 'axhub-annotation-source-markdown',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
const filePath = cleanModuleId(id);
|
||||
if (path.basename(filePath) !== 'annotation-source.json') {
|
||||
return null;
|
||||
}
|
||||
if (!getPrototypeDirFromAnnotationSourcePath(projectRoot, filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = JSON.parse(code) as AnnotationSourceLike;
|
||||
const result = preprocessAnnotationSourceMarkdown({
|
||||
projectRoot,
|
||||
sourceFilePath: filePath,
|
||||
source,
|
||||
mode,
|
||||
});
|
||||
for (const watchFile of result.watchFiles) {
|
||||
this.addWatchFile(watchFile);
|
||||
}
|
||||
return `${JSON.stringify(result.source, null, 2)}\n`;
|
||||
},
|
||||
};
|
||||
}
|
||||
475
vite-plugins/autoStartMakeServerPlugin.ts
Normal file
475
vite-plugins/autoStartMakeServerPlugin.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
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:53817';
|
||||
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', '--no-open'];
|
||||
if (runtimeOrigin) {
|
||||
args.push('--runtime-origin', runtimeOrigin);
|
||||
}
|
||||
return {
|
||||
command: process.execPath,
|
||||
args,
|
||||
label: 'local @axhub/make dev',
|
||||
};
|
||||
}
|
||||
const args = ['-y', '@axhub/make@latest', projectRoot, '--no-open'];
|
||||
if (runtimeOrigin) {
|
||||
args.push('--runtime-origin', runtimeOrigin);
|
||||
}
|
||||
return {
|
||||
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
args,
|
||||
label: 'npx -y @axhub/make@latest',
|
||||
};
|
||||
}
|
||||
|
||||
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 (!health || typeof health !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const payload = health as { role?: unknown; devMode?: unknown };
|
||||
if (payload.role !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
if (options.requireDevMode && payload.devMode !== true) {
|
||||
return false;
|
||||
}
|
||||
return 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');
|
||||
}
|
||||
69
vite-plugins/axhubComponentEnforcer.ts
Normal file
69
vite-plugins/axhubComponentEnforcer.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
206
vite-plugins/canvasHotUpdateFilter.test.ts
Normal file
206
vite-plugins/canvasHotUpdateFilter.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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/generation-artifacts.json')).toBe(true);
|
||||
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/.spec/review.md')).toBe(true);
|
||||
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/annotation-source.json')).toBe(true);
|
||||
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/annotation-source.json?import&t=123')).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/generation-artifacts.json',
|
||||
modules: [{ id: 'history' }],
|
||||
})).toEqual([]);
|
||||
expect(await handleHotUpdate({
|
||||
file: '/project/src/prototypes/home/annotation-source.json',
|
||||
modules: [{ id: 'annotation-source' }],
|
||||
})).toEqual([]);
|
||||
expect(await handleHotUpdate({
|
||||
file: '/project/src/prototypes/home/index.tsx',
|
||||
modules: [{ id: 'index' }],
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('invalidates annotation source modules while suppressing browser reloads', async () => {
|
||||
const plugin = canvasHotUpdateFilterPlugin();
|
||||
const handleHotUpdate = plugin.handleHotUpdate as any;
|
||||
const annotationModule = { id: 'annotation-source' };
|
||||
const otherModule = { id: 'canvas' };
|
||||
const invalidateModule = vi.fn();
|
||||
|
||||
expect(await handleHotUpdate({
|
||||
file: '/project/src/prototypes/home/annotation-source.json',
|
||||
modules: [annotationModule, otherModule],
|
||||
server: {
|
||||
moduleGraph: { invalidateModule },
|
||||
},
|
||||
timestamp: 123,
|
||||
})).toEqual([]);
|
||||
|
||||
expect(invalidateModule).toHaveBeenCalledWith(annotationModule, undefined, 123, true);
|
||||
expect(invalidateModule).toHaveBeenCalledWith(otherModule, undefined, 123, true);
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
server.ws.send({
|
||||
type: 'full-reload',
|
||||
triggeredBy: '/project/src/prototypes/home/annotation-source.json',
|
||||
});
|
||||
|
||||
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('drops Vite update payloads caused only by annotation source modules', async () => {
|
||||
const hotSend = vi.fn();
|
||||
const server = {
|
||||
hot: { send: hotSend },
|
||||
ws: { send: vi.fn() },
|
||||
};
|
||||
const plugin = canvasHotUpdateFilterPlugin();
|
||||
|
||||
await runConfigureServer(plugin, server);
|
||||
|
||||
server.hot.send({
|
||||
type: 'update',
|
||||
updates: [{
|
||||
type: 'js-update',
|
||||
timestamp: 1,
|
||||
path: '/src/prototypes/home/annotation-source.json?import&t=123',
|
||||
acceptedPath: '/src/prototypes/home/annotation-source.json?import&t=123',
|
||||
}],
|
||||
});
|
||||
|
||||
expect(hotSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps non-canvas Vite updates when they are batched with annotation source modules', async () => {
|
||||
const hotSend = vi.fn();
|
||||
const server = {
|
||||
hot: { send: hotSend },
|
||||
ws: { send: vi.fn() },
|
||||
};
|
||||
const plugin = canvasHotUpdateFilterPlugin();
|
||||
|
||||
await runConfigureServer(plugin, server);
|
||||
|
||||
server.hot.send({
|
||||
type: 'update',
|
||||
updates: [
|
||||
{
|
||||
type: 'js-update',
|
||||
timestamp: 1,
|
||||
path: '/src/prototypes/home/annotation-source.json?import&t=123',
|
||||
acceptedPath: '/src/prototypes/home/annotation-source.json?import&t=123',
|
||||
},
|
||||
{
|
||||
type: 'js-update',
|
||||
timestamp: 1,
|
||||
path: '/src/prototypes/home/index.tsx',
|
||||
acceptedPath: '/src/prototypes/home/index.tsx',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(hotSend).toHaveBeenCalledTimes(1);
|
||||
expect(hotSend.mock.calls[0]?.[0]).toEqual({
|
||||
type: 'update',
|
||||
updates: [{
|
||||
type: 'js-update',
|
||||
timestamp: 1,
|
||||
path: '/src/prototypes/home/index.tsx',
|
||||
acceptedPath: '/src/prototypes/home/index.tsx',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
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/generation-artifacts.json',
|
||||
})).toBe(true);
|
||||
expect(shouldDropCanvasFullReloadPayload({
|
||||
type: 'full-reload',
|
||||
triggeredBy: '/project/src/prototypes/home/annotation-source.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);
|
||||
});
|
||||
});
|
||||
129
vite-plugins/canvasHotUpdateFilter.ts
Normal file
129
vite-plugins/canvasHotUpdateFilter.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { normalizePath, type HMRPayload, type HmrContext, type Plugin, type ViteDevServer } from 'vite';
|
||||
|
||||
type SendFunction = (...args: any[]) => void;
|
||||
|
||||
const CANVAS_ASSETS_SEGMENT = '/canvas-assets/';
|
||||
const SPEC_SEGMENT = '/.spec/';
|
||||
const ANNOTATION_SOURCE_FILE_NAME = '/annotation-source.json';
|
||||
|
||||
function cleanHotUpdatePath(filePath: string): string {
|
||||
return normalizePath(filePath).split(/[?#]/u)[0] || '';
|
||||
}
|
||||
|
||||
export function isCanvasHotUpdateFile(filePath: string): boolean {
|
||||
const normalized = cleanHotUpdatePath(filePath);
|
||||
return (
|
||||
normalized.endsWith('.excalidraw')
|
||||
|| normalized.endsWith(ANNOTATION_SOURCE_FILE_NAME)
|
||||
|| normalized.includes(CANVAS_ASSETS_SEGMENT)
|
||||
|| normalized.includes(SPEC_SEGMENT)
|
||||
);
|
||||
}
|
||||
|
||||
function isAnnotationSourceHotUpdateFile(filePath: string): boolean {
|
||||
return cleanHotUpdatePath(filePath).endsWith(ANNOTATION_SOURCE_FILE_NAME);
|
||||
}
|
||||
|
||||
function invalidateHotUpdateModules(ctx: HmrContext): void {
|
||||
const moduleGraph = ctx.server?.moduleGraph;
|
||||
if (!moduleGraph) return;
|
||||
for (const module of ctx.modules) {
|
||||
moduleGraph.invalidateModule(module, undefined, ctx.timestamp, true);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isCanvasUpdateRecord(update: unknown): boolean {
|
||||
if (!update || typeof update !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const record = update as { path?: unknown; acceptedPath?: unknown };
|
||||
return [record.path, record.acceptedPath].some((value) => (
|
||||
typeof value === 'string' && isCanvasHotUpdateFile(value)
|
||||
));
|
||||
}
|
||||
|
||||
function filterCanvasUpdatePayload(payload: HMRPayload): HMRPayload | null {
|
||||
if (!payload || typeof payload !== 'object' || payload.type !== 'update' || !Array.isArray((payload as any).updates)) {
|
||||
return payload;
|
||||
}
|
||||
const updates = (payload as any).updates.filter((update: unknown) => !isCanvasUpdateRecord(update));
|
||||
if (updates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (updates.length === (payload as any).updates.length) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...(payload as any),
|
||||
updates,
|
||||
} as HMRPayload;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const filteredPayload = filterCanvasUpdatePayload(payload);
|
||||
if (!filteredPayload) {
|
||||
return;
|
||||
}
|
||||
if (filteredPayload !== payload) {
|
||||
return originalSend(filteredPayload, ...args.slice(1));
|
||||
}
|
||||
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)) {
|
||||
if (isAnnotationSourceHotUpdateFile(ctx.file)) {
|
||||
invalidateHotUpdateModules(ctx);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
963
vite-plugins/clientPreviewPlugin.ts
Normal file
963
vite-plugins/clientPreviewPlugin.ts
Normal file
@@ -0,0 +1,963 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
import {
|
||||
fetchHealth,
|
||||
normalizeHealthServerInfo,
|
||||
readServerInfo,
|
||||
} from '../scripts/utils/serverInfo.mjs';
|
||||
|
||||
import {
|
||||
appendProjectIdToModuleSpecifiersInCode,
|
||||
appendSearchParamToModuleSpecifier,
|
||||
} from './utils/moduleSpecifierQuery';
|
||||
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';
|
||||
const PREVIEW_LOADER_FILE = '__axhub-preview-loader.js';
|
||||
const DEFAULT_ADMIN_ORIGIN = 'http://localhost:53817';
|
||||
const REACT_REFRESH_PREAMBLE_MARKER = 'data-axhub-react-refresh-preamble';
|
||||
const REACT_REFRESH_PREAMBLE_SCRIPT = `<script type="module" ${REACT_REFRESH_PREAMBLE_MARKER}>
|
||||
import { injectIntoGlobalHook } from "/@react-refresh";
|
||||
injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
</script>`;
|
||||
|
||||
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 createRoutePath(type: ResourceType, name: string): string {
|
||||
return encodeRoutePath(`/${type}/${name}`);
|
||||
}
|
||||
|
||||
function createRawRoutePath(type: ResourceType, name: string): string {
|
||||
return `/${type}/${name}`;
|
||||
}
|
||||
|
||||
function createPreviewLoaderPath(type: ResourceType, name: string): string {
|
||||
return `${createRoutePath(type, name)}/${PREVIEW_LOADER_FILE}`;
|
||||
}
|
||||
|
||||
function getSearchParamFromRequestUrl(requestUrl: string, key: string): string {
|
||||
try {
|
||||
return new URL(requestUrl || '/', 'http://localhost').searchParams.get(key)?.trim() || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchParamFromRequestReferer(
|
||||
req: { headers?: Record<string, string | string[] | undefined> },
|
||||
key: string,
|
||||
): string {
|
||||
const referer = getHeaderValue(req.headers?.referer || req.headers?.referrer).trim();
|
||||
return referer ? getSearchParamFromRequestUrl(referer, key) : '';
|
||||
}
|
||||
|
||||
function appendPreviewLoaderSearchParams(loaderPath: string, requestUrl: string): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const key of ['projectId', 'gitVersion', 'gitPath']) {
|
||||
const value = getSearchParamFromRequestUrl(requestUrl, key);
|
||||
if (value) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
const search = searchParams.toString();
|
||||
return search ? `${loaderPath}?${search}` : loaderPath;
|
||||
}
|
||||
|
||||
function handleHtmlProxyModuleRequestWithProjectContext(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
next: () => void,
|
||||
projectId: string,
|
||||
): void {
|
||||
const chunks: Buffer[] = [];
|
||||
const originalWrite = res.write.bind(res);
|
||||
const originalEnd = res.end.bind(res);
|
||||
|
||||
const restore = () => {
|
||||
res.write = originalWrite as ServerResponse['write'];
|
||||
res.end = originalEnd as ServerResponse['end'];
|
||||
};
|
||||
|
||||
res.write = function writeCapturedHtmlProxyChunk(
|
||||
chunk: any,
|
||||
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
||||
callback?: (error?: Error | null) => void,
|
||||
) {
|
||||
if (chunk) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8'));
|
||||
}
|
||||
const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
|
||||
done?.();
|
||||
return true;
|
||||
} as ServerResponse['write'];
|
||||
|
||||
res.end = function endCapturedHtmlProxyResponse(
|
||||
chunk?: any,
|
||||
encodingOrCallback?: BufferEncoding | (() => void),
|
||||
callback?: () => void,
|
||||
) {
|
||||
if (chunk) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8'));
|
||||
}
|
||||
restore();
|
||||
|
||||
const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
|
||||
const contentType = String(res.getHeader('content-type') || res.getHeader('Content-Type') || '').toLowerCase();
|
||||
const body = chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0);
|
||||
if (res.statusCode >= 400 || !contentType.includes('javascript')) {
|
||||
return originalEnd(body.length > 0 ? body : undefined, done);
|
||||
}
|
||||
|
||||
const rewritten = Buffer.from(appendProjectIdToModuleSpecifiersInCode(body.toString('utf8'), projectId), 'utf8');
|
||||
res.setHeader('Content-Length', String(rewritten.length));
|
||||
return originalEnd(rewritten, done);
|
||||
} as ServerResponse['end'];
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function decodePathParts(pathname: string): string[] {
|
||||
return pathname.split('/').filter(Boolean).map((part) => {
|
||||
try {
|
||||
return decodeURIComponent(part);
|
||||
} catch {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isSafePathPart(part: string): boolean {
|
||||
return Boolean(part) && !part.includes('/') && !part.includes('\\') && !part.includes('\0');
|
||||
}
|
||||
|
||||
function hasPreviewEntry(projectRoot: string, type: ResourceType, nameParts: string[]): boolean {
|
||||
if (!PREVIEW_TYPES.has(type) || nameParts.length === 0 || nameParts.some((part) => !isSafePathPart(part) || part === '..')) {
|
||||
return false;
|
||||
}
|
||||
const resourceDir = path.resolve(projectRoot, 'src', type, ...nameParts);
|
||||
return fs.existsSync(path.join(resourceDir, 'index.tsx'))
|
||||
|| fs.existsSync(path.join(resourceDir, 'index.ts'));
|
||||
}
|
||||
|
||||
function stripPreviewEntryHtmlSuffix(pathname: string, suffixPattern: RegExp, projectRoot: string): string {
|
||||
if (!suffixPattern.test(pathname)) {
|
||||
return pathname;
|
||||
}
|
||||
const previewPathname = pathname.replace(suffixPattern, '');
|
||||
const previewParts = decodePathParts(previewPathname);
|
||||
const previewType = previewParts[0] as ResourceType;
|
||||
const previewNameParts = previewParts.slice(1);
|
||||
return hasPreviewEntry(projectRoot, previewType, previewNameParts) ? previewPathname : pathname;
|
||||
}
|
||||
|
||||
function normalizeRoute(
|
||||
url: string,
|
||||
projectRoot = process.cwd(),
|
||||
): { type: ResourceType; name: string; action: 'preview' | 'spec'; assetPath?: string } | null {
|
||||
const rawPathname = url.split('?')[0] || '';
|
||||
let pathname = stripPreviewEntryHtmlSuffix(rawPathname, /\/index\.html$/iu, projectRoot);
|
||||
pathname = stripPreviewEntryHtmlSuffix(pathname, /\.html$/iu, projectRoot);
|
||||
const parts = decodePathParts(pathname);
|
||||
if (parts.some((part) => !isSafePathPart(part))) {
|
||||
return null;
|
||||
}
|
||||
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|html?|png|jpe?g|webp|svg|gif|avif|ico|json|txt|woff2?|ttf|otf|eot)$/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);
|
||||
} else {
|
||||
const resourceRoot = path.resolve(projectRoot, 'src', type);
|
||||
let resolvedNameParts = nameParts;
|
||||
let resolvedAssetParts = assetParts;
|
||||
for (let splitIndex = 2; splitIndex < parts.length; splitIndex += 1) {
|
||||
const candidateNameParts = parts.slice(1, splitIndex);
|
||||
const candidateResourceDir = path.resolve(resourceRoot, ...candidateNameParts);
|
||||
if (
|
||||
fs.existsSync(path.join(candidateResourceDir, 'index.tsx'))
|
||||
|| fs.existsSync(path.join(candidateResourceDir, 'index.ts'))
|
||||
) {
|
||||
resolvedNameParts = candidateNameParts;
|
||||
resolvedAssetParts = parts.slice(splitIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
nameParts = resolvedNameParts;
|
||||
assetParts = resolvedAssetParts;
|
||||
}
|
||||
}
|
||||
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 normalizePreviewLoaderRoute(url: string): { type: ResourceType; name: string } | null {
|
||||
const pathname = url.split('?')[0] || '';
|
||||
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 < 3 || parts[parts.length - 1] !== PREVIEW_LOADER_FILE) {
|
||||
return null;
|
||||
}
|
||||
const nameParts = parts.slice(1, -1);
|
||||
const name = nameParts.join('/');
|
||||
if (!name || nameParts.some((part) => !part || part === '..')) {
|
||||
return null;
|
||||
}
|
||||
return { type, name };
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function toViteFsPath(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
return normalized.startsWith('/') ? `/@fs${normalized}` : `/@fs/${normalized}`;
|
||||
}
|
||||
|
||||
function normalizeSafeRelativePath(value: string): string {
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/u, '')
|
||||
.replace(/\/+$/u, '');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..' || part.includes('\0'))) {
|
||||
return '';
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function normalizeGitVersionId(value: string): string {
|
||||
const trimmed = String(value || '').trim();
|
||||
return /^[a-f0-9]{7,64}$/iu.test(trimmed) ? trimmed : '';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 injectReactRefreshPreambleScript(html: string): string {
|
||||
if (
|
||||
html.includes(REACT_REFRESH_PREAMBLE_MARKER)
|
||||
|| (
|
||||
html.includes('injectIntoGlobalHook(window)')
|
||||
&& html.includes('window.$RefreshReg$')
|
||||
&& html.includes('/@react-refresh')
|
||||
)
|
||||
) {
|
||||
return html;
|
||||
}
|
||||
return html.includes('</head>')
|
||||
? html.replace('</head>', ` ${REACT_REFRESH_PREAMBLE_SCRIPT}\n</head>`)
|
||||
: `${REACT_REFRESH_PREAMBLE_SCRIPT}\n${html}`;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
interface PreviewSource {
|
||||
resourceDir: string;
|
||||
entryPath: string;
|
||||
importPath: string;
|
||||
stylePath: string;
|
||||
styleHref: string;
|
||||
}
|
||||
|
||||
function createDefaultPreviewSource(projectRoot: string, route: { type: ResourceType; name: string }): PreviewSource {
|
||||
const resourceDir = path.resolve(projectRoot, 'src', route.type, route.name);
|
||||
const routePath = createRawRoutePath(route.type, route.name);
|
||||
return {
|
||||
resourceDir,
|
||||
entryPath: path.join(resourceDir, 'index.tsx'),
|
||||
importPath: `${routePath}/index.tsx`,
|
||||
stylePath: path.join(resourceDir, 'style.css'),
|
||||
styleHref: `${routePath}/style.css`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGitVersionPreviewSource(
|
||||
projectRoot: string,
|
||||
route: { type: ResourceType; name: string },
|
||||
requestUrl: string,
|
||||
): PreviewSource | null {
|
||||
if (route.type !== 'prototypes') {
|
||||
return null;
|
||||
}
|
||||
const versionId = normalizeGitVersionId(getSearchParamFromRequestUrl(requestUrl, 'gitVersion'));
|
||||
if (!versionId) {
|
||||
return null;
|
||||
}
|
||||
const explicitGitPath = normalizeSafeRelativePath(getSearchParamFromRequestUrl(requestUrl, 'gitPath'));
|
||||
const fallbackPath = normalizeSafeRelativePath(`${route.type}/${route.name}`);
|
||||
const relativeCandidates = Array.from(new Set([
|
||||
explicitGitPath,
|
||||
explicitGitPath ? `src/${explicitGitPath}` : '',
|
||||
fallbackPath,
|
||||
fallbackPath ? `src/${fallbackPath}` : '',
|
||||
].filter(Boolean)));
|
||||
const versionsRoot = path.resolve(projectRoot, '.git-versions');
|
||||
const versionRoot = path.resolve(versionsRoot, versionId);
|
||||
if (!versionRoot.startsWith(versionsRoot + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const routePath = createRoutePath(route.type, route.name);
|
||||
const styleSearchParams = new URLSearchParams({ gitVersion: versionId });
|
||||
if (explicitGitPath) {
|
||||
styleSearchParams.set('gitPath', explicitGitPath);
|
||||
}
|
||||
|
||||
for (const relativePath of relativeCandidates) {
|
||||
const resourceDir = path.resolve(versionRoot, ...relativePath.split('/'));
|
||||
if (!resourceDir.startsWith(versionRoot + path.sep)) {
|
||||
continue;
|
||||
}
|
||||
const entryPath = path.join(resourceDir, 'index.tsx');
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
continue;
|
||||
}
|
||||
const stylePath = path.join(resourceDir, 'style.css');
|
||||
return {
|
||||
resourceDir,
|
||||
entryPath,
|
||||
importPath: toViteFsPath(entryPath),
|
||||
stylePath,
|
||||
styleHref: `${routePath}/style.css?${styleSearchParams.toString()}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePreviewSource(
|
||||
projectRoot: string,
|
||||
route: { type: ResourceType; name: string },
|
||||
requestUrl: string,
|
||||
): PreviewSource {
|
||||
return resolveGitVersionPreviewSource(projectRoot, route, requestUrl)
|
||||
|| createDefaultPreviewSource(projectRoot, route);
|
||||
}
|
||||
|
||||
function createPreviewLoader(
|
||||
type: ResourceType,
|
||||
name: string,
|
||||
projectRoot: string,
|
||||
previewSource: PreviewSource,
|
||||
requestUrl: string,
|
||||
) {
|
||||
const projectId = getSearchParamFromRequestUrl(requestUrl, 'projectId');
|
||||
const importPath = appendSearchParamToModuleSpecifier(previewSource.importPath, 'projectId', projectId);
|
||||
const previewPath = createRawRoutePath(type, name);
|
||||
return `
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import PreviewComponent from ${JSON.stringify(importPath)};
|
||||
|
||||
class AxhubPreviewErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
window.axhub?.prototypeRuntime?.reportError?.(error, {
|
||||
type: 'react-render',
|
||||
sourceFile: ${JSON.stringify(importPath)},
|
||||
componentStack: errorInfo?.componentStack,
|
||||
resourceType: ${JSON.stringify(type === 'prototypes' ? 'prototype' : 'theme')},
|
||||
resourceId: ${JSON.stringify(name)},
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return React.createElement('div', {
|
||||
style: {
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: 24,
|
||||
boxSizing: 'border-box',
|
||||
color: '#111827',
|
||||
background: '#f6f7f9',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
},
|
||||
}, React.createElement('div', {
|
||||
style: {
|
||||
width: 'min(520px, 100%)',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: 8,
|
||||
background: '#ffffff',
|
||||
padding: 20,
|
||||
boxShadow: '0 18px 50px rgba(17, 24, 39, 0.10)',
|
||||
},
|
||||
}, [
|
||||
React.createElement('strong', { key: 'title' }, '原型运行错误'),
|
||||
React.createElement('p', {
|
||||
key: 'message',
|
||||
style: { margin: '10px 0 0', color: '#4b5563', overflowWrap: 'anywhere' },
|
||||
}, this.state.error?.message || '页面渲染失败'),
|
||||
]));
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
function renderPreview(Component) {
|
||||
root.render(React.createElement(AxhubPreviewErrorBoundary, null, React.createElement(Component, {
|
||||
container: rootElement,
|
||||
config: {
|
||||
projectPath: ${JSON.stringify(projectRoot)},
|
||||
},
|
||||
data: {},
|
||||
events: {},
|
||||
})));
|
||||
}
|
||||
|
||||
renderPreview(PreviewComponent);
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(${JSON.stringify(importPath)}, (nextModule) => {
|
||||
const NextComponent = nextModule?.default || PreviewComponent;
|
||||
renderPreview(NextComponent);
|
||||
notifyAxhubPreviewUpdated('hmr');
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function createPreviewLoaderScriptTag(
|
||||
type: ResourceType,
|
||||
name: string,
|
||||
requestUrl: string,
|
||||
): string {
|
||||
const src = appendPreviewLoaderSearchParams(createPreviewLoaderPath(type, name), requestUrl);
|
||||
return `<script type="module" src="${src}"></script>`;
|
||||
}
|
||||
|
||||
function replacePreviewLoaderPlaceholder(
|
||||
html: string,
|
||||
type: ResourceType,
|
||||
name: string,
|
||||
requestUrl: string,
|
||||
): string {
|
||||
const scriptTag = createPreviewLoaderScriptTag(type, name, requestUrl);
|
||||
const inlineModulePattern = /(\s*)<script\b[^>]*type=["']module["'][^>]*>\s*\{\{PREVIEW_LOADER\}\}\s*<\/script>/u;
|
||||
if (inlineModulePattern.test(html)) {
|
||||
return html.replace(inlineModulePattern, (_match, leadingWhitespace: string) => `${leadingWhitespace}${scriptTag}`);
|
||||
}
|
||||
return html.replace(/\{\{PREVIEW_LOADER\}\}/g, scriptTag);
|
||||
}
|
||||
|
||||
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',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.gif': 'image/gif',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
};
|
||||
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 requiresViteCssTransform(filePath: string): boolean {
|
||||
if (path.extname(filePath).toLowerCase() !== '.css') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
return /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"')]*)?["']/u.test(source);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isViteAssetModuleRequest(requestUrl: string): boolean {
|
||||
try {
|
||||
const searchParams = new URL(requestUrl || '/', 'http://localhost').searchParams;
|
||||
return ['import', 'url', 'raw', 'inline', 'worker', 'sharedworker'].some((key) => searchParams.has(key));
|
||||
} catch {
|
||||
return /[?&](?:import|url|raw|inline|worker|sharedworker)(?:[=&]|$)/u.test(requestUrl || '');
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePreviewAssetPath(projectRoot: string, route: {
|
||||
type: ResourceType;
|
||||
name: string;
|
||||
assetPath: string;
|
||||
resourceDir?: string;
|
||||
}): string | null {
|
||||
const resourceDir = route.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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const forwardedHostHeader = getHeaderValue(req.headers?.['x-forwarded-host']).split(',')[0]?.trim() || '';
|
||||
const hostHeader = forwardedHostHeader || getHeaderValue(req.headers?.host).trim();
|
||||
if (!hostHeader) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const requestUrl = new URL(`http://${hostHeader}`);
|
||||
const requestHost = requestUrl.hostname;
|
||||
const explicitPort = Number(requestUrl.port);
|
||||
const port = forwardedHostHeader && Number.isInteger(explicitPort) && explicitPort > 0
|
||||
? explicitPort
|
||||
: adminInfo?.port;
|
||||
if (!port) {
|
||||
return null;
|
||||
}
|
||||
if (!forwardedHostHeader && isLocalHostname(requestHost)) {
|
||||
return null;
|
||||
}
|
||||
return `${getRequestProtocol(req)}://${formatUrlHost(requestHost)}:${port}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAdminHealthPayload(data: unknown): boolean {
|
||||
return Boolean(data && typeof data === 'object' && (data as { role?: unknown }).role === 'admin');
|
||||
}
|
||||
|
||||
async function isHealthyAdminOrigin(origin: string | null | undefined): Promise<boolean> {
|
||||
if (!origin) {
|
||||
return false;
|
||||
}
|
||||
const health = await fetchHealth(origin, 600);
|
||||
return isAdminHealthPayload(health) && Boolean(normalizeHealthServerInfo(health)?.origin);
|
||||
}
|
||||
|
||||
async function resolveAdminServerOrigin(
|
||||
projectRoot: string,
|
||||
req: { headers?: Record<string, string | string[] | undefined> },
|
||||
): Promise<string | null> {
|
||||
const embeddedAdminOrigin = getRequestRefererOrigin(req);
|
||||
if (embeddedAdminOrigin) {
|
||||
if (await isHealthyAdminOrigin(embeddedAdminOrigin)) {
|
||||
return embeddedAdminOrigin;
|
||||
}
|
||||
}
|
||||
|
||||
const info = readServerInfo(projectRoot, 'admin');
|
||||
const requestHostAdminOrigin = createNetworkAdminOriginFromRequestHost(req, info);
|
||||
if (requestHostAdminOrigin) {
|
||||
if (await isHealthyAdminOrigin(requestHostAdminOrigin)) {
|
||||
return requestHostAdminOrigin;
|
||||
}
|
||||
}
|
||||
|
||||
if (await isHealthyAdminOrigin(info?.origin)) {
|
||||
return info?.origin || null;
|
||||
}
|
||||
|
||||
if (info?.origin !== DEFAULT_ADMIN_ORIGIN && await isHealthyAdminOrigin(DEFAULT_ADMIN_ORIGIN)) {
|
||||
return DEFAULT_ADMIN_ORIGIN;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clientPreviewPlugin(): Plugin {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
return {
|
||||
name: 'make-project-client-preview',
|
||||
apply: 'serve',
|
||||
resolveId(id) {
|
||||
if (normalizePreviewLoaderRoute(id)) {
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
load(id) {
|
||||
const loaderRoute = normalizePreviewLoaderRoute(id);
|
||||
if (!loaderRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewSource = resolvePreviewSource(projectRoot, loaderRoute, id);
|
||||
if (!fs.existsSync(previewSource.entryPath)) {
|
||||
return null;
|
||||
}
|
||||
return createPreviewLoader(loaderRoute.type, loaderRoute.name, projectRoot, previewSource, id);
|
||||
},
|
||||
async configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
try {
|
||||
if (!req.url || req.method !== 'GET') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizePreviewLoaderRoute(req.url)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHtmlProxyModuleRequest(req.url)) {
|
||||
const projectId = getSearchParamFromRequestUrl(req.url, 'projectId')
|
||||
|| getSearchParamFromRequestReferer(req, 'projectId');
|
||||
if (projectId) {
|
||||
handleHtmlProxyModuleRequestWithProjectContext(req, res, next, projectId);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const route = normalizeRoute(req.url, projectRoot);
|
||||
if (!route) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const previewSource = resolvePreviewSource(projectRoot, route, req.url);
|
||||
const entryPath = previewSource.entryPath;
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.assetPath) {
|
||||
if (isViteAssetModuleRequest(req.url)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (isCssModuleRequest(req, route.assetPath)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const assetPath = resolvePreviewAssetPath(projectRoot, {
|
||||
type: route.type,
|
||||
name: route.name,
|
||||
assetPath: route.assetPath,
|
||||
resourceDir: previewSource.resourceDir,
|
||||
});
|
||||
if (assetPath && requiresViteCssTransform(assetPath)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
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 = previewSource.stylePath;
|
||||
if (fs.existsSync(stylePath)) {
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
` <link rel="stylesheet" href="${previewSource.styleHref}">\n</head>`,
|
||||
);
|
||||
}
|
||||
html = injectQuickEditRuntimeScript(html, serverOrigin);
|
||||
html = injectDevTemplateBootstrapScript(html, serverOrigin);
|
||||
html = replacePreviewLoaderPlaceholder(html, route.type, route.name, req.url);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
const transformedHtml = await server.transformIndexHtml(
|
||||
createPreviewTransformUrl(route.type, route.name),
|
||||
html,
|
||||
);
|
||||
res.end(injectReactRefreshPreambleScript(transformedHtml));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
transformIndexHtml(html) {
|
||||
if (!html.includes('{{PREVIEW_LOADER}}')) {
|
||||
return html;
|
||||
}
|
||||
return html.replace(new RegExp(escapeRegExp('{{PREVIEW_LOADER}}'), 'g'), '');
|
||||
},
|
||||
};
|
||||
}
|
||||
19
vite-plugins/forceInlineDynamicImportsOff.ts
Normal file
19
vite-plugins/forceInlineDynamicImportsOff.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
113
vite-plugins/injectStablePageIds.ts
Normal file
113
vite-plugins/injectStablePageIds.ts
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
44
vite-plugins/portOccupancy.test.ts
Normal file
44
vite-plugins/portOccupancy.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
55
vite-plugins/utils/entriesManifest.ts
Normal file
55
vite-plugins/utils/entriesManifest.ts
Normal 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);
|
||||
}
|
||||
24
vite-plugins/utils/entriesManifestCore.d.ts
vendored
Normal file
24
vite-plugins/utils/entriesManifestCore.d.ts
vendored
Normal 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;
|
||||
247
vite-plugins/utils/entriesManifestCore.js
Normal file
247
vite-plugins/utils/entriesManifestCore.js
Normal 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);
|
||||
}
|
||||
15
vite-plugins/utils/httpUtils.ts
Normal file
15
vite-plugins/utils/httpUtils.ts
Normal 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';
|
||||
}
|
||||
7
vite-plugins/utils/makeConstants.ts
Normal file
7
vite-plugins/utils/makeConstants.ts
Normal 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';
|
||||
49
vite-plugins/utils/moduleSpecifierQuery.ts
Normal file
49
vite-plugins/utils/moduleSpecifierQuery.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function shouldSkipModuleSpecifier(specifier: string, key: string, value: string): boolean {
|
||||
const pathOnly = specifier.split(/[?#]/u)[0] || specifier;
|
||||
return !value
|
||||
|| !specifier.startsWith('/')
|
||||
|| pathOnly === '/@react-refresh'
|
||||
|| pathOnly === '/@vite/client'
|
||||
|| new RegExp(`[?&]${escapeRegExp(key)}=`).test(specifier);
|
||||
}
|
||||
|
||||
export function appendSearchParamToModuleSpecifier(specifier: string, key: string, value: string): string {
|
||||
if (shouldSkipModuleSpecifier(specifier, key, value)) {
|
||||
return specifier;
|
||||
}
|
||||
const hashIndex = specifier.indexOf('#');
|
||||
const withoutHash = hashIndex >= 0 ? specifier.slice(0, hashIndex) : specifier;
|
||||
const hash = hashIndex >= 0 ? specifier.slice(hashIndex) : '';
|
||||
const separator = withoutHash.includes('?') ? '&' : '?';
|
||||
return `${withoutHash}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
|
||||
}
|
||||
|
||||
export function appendSearchParamToModuleSpecifiersInCode(code: string, key: string, value: string): string {
|
||||
if (!value) {
|
||||
return code;
|
||||
}
|
||||
return code
|
||||
.replace(
|
||||
/(\bfrom\s*["'])(\/[^"']+)(["'])/gu,
|
||||
(_match, prefix: string, specifier: string, suffix: string) =>
|
||||
`${prefix}${appendSearchParamToModuleSpecifier(specifier, key, value)}${suffix}`,
|
||||
)
|
||||
.replace(
|
||||
/(\bimport\s*["'])(\/[^"']+)(["'])/gu,
|
||||
(_match, prefix: string, specifier: string, suffix: string) =>
|
||||
`${prefix}${appendSearchParamToModuleSpecifier(specifier, key, value)}${suffix}`,
|
||||
)
|
||||
.replace(
|
||||
/(\bimport\s*\(\s*["'])(\/[^"']+)(["']\s*\))/gu,
|
||||
(_match, prefix: string, specifier: string, suffix: string) =>
|
||||
`${prefix}${appendSearchParamToModuleSpecifier(specifier, key, value)}${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function appendProjectIdToModuleSpecifiersInCode(code: string, projectId: string): string {
|
||||
return appendSearchParamToModuleSpecifiersInCode(code, 'projectId', projectId);
|
||||
}
|
||||
103
vite-plugins/utils/portOccupancy.ts
Normal file
103
vite-plugins/utils/portOccupancy.ts
Normal 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;
|
||||
}
|
||||
40
vite-plugins/utils/previewTitle.ts
Normal file
40
vite-plugins/utils/previewTitle.ts
Normal 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]}`;
|
||||
}
|
||||
812
vite-plugins/websocketPlugin.ts
Normal file
812
vite-plugins/websocketPlugin.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
201
vite-plugins/writeDevServerInfoPlugin.ts
Normal file
201
vite-plugins/writeDevServerInfoPlugin.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
const VITE_CLIENT_MARKERS = [
|
||||
'const importMetaUrl = new URL(import.meta.url);',
|
||||
'const directSocketHost = ',
|
||||
'"vite-hmr"',
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getActualListeningPort(server: any): number | null {
|
||||
const address = server?.httpServer?.address?.();
|
||||
return address && typeof address === 'object' && typeof address.port === 'number'
|
||||
? address.port
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceInjectedHostPort(code: string, constName: string, port: number): string {
|
||||
return code.replace(
|
||||
new RegExp(`(const ${constName} = ")([^"]+)(";)$`, 'mu'),
|
||||
(_match, prefix: string, value: string, suffix: string) => {
|
||||
const nextValue = value.replace(/:\d+(\/[^"]*)?$/u, (_portMatch, tail = '') => `:${port}${tail}`);
|
||||
return `${prefix}${nextValue}${suffix}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function hasExplicitHmrPort(server: any): boolean {
|
||||
const hmr = server?.config?.server?.hmr;
|
||||
return Boolean(hmr && typeof hmr === 'object' && Number.isInteger(Number(hmr.port)));
|
||||
}
|
||||
|
||||
function alignInjectedViteClientHmrPort(code: string, server: any): string {
|
||||
const actualPort = getActualListeningPort(server);
|
||||
if (!actualPort) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const withServerHost = replaceInjectedHostPort(code, 'serverHost', actualPort);
|
||||
return hasExplicitHmrPort(server)
|
||||
? withServerHost
|
||||
: replaceInjectedHostPort(withServerHost, 'directSocketHost', actualPort);
|
||||
}
|
||||
|
||||
function isViteClientRequest(requestUrl: string): boolean {
|
||||
try {
|
||||
return new URL(requestUrl || '/', 'http://localhost').pathname === '/@vite/client';
|
||||
} catch {
|
||||
return (requestUrl || '').split('?')[0] === '/@vite/client';
|
||||
}
|
||||
}
|
||||
|
||||
function applyServerHeaders(res: any, headers: Record<string, string> | undefined): void {
|
||||
if (!headers) {
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAlignedViteClient(req: any, res: any, next: (error?: unknown) => void, server: any): Promise<void> {
|
||||
if (req.method !== 'GET' || !isViteClientRequest(req.url || '/')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await server.transformRequest('/@vite/client');
|
||||
if (!result) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const code = VITE_CLIENT_MARKERS.every((marker) => result.code.includes(marker))
|
||||
? alignInjectedViteClientHmrPort(result.code, server)
|
||||
: result.code;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
if (result.etag) {
|
||||
res.setHeader('Etag', result.etag);
|
||||
}
|
||||
applyServerHeaders(res, server.config.server?.headers);
|
||||
res.end(code);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDevServerInfoPlugin(): Plugin {
|
||||
return {
|
||||
name: 'write-dev-server-info',
|
||||
configureServer(server: any) {
|
||||
const startedAt = new Date().toISOString();
|
||||
server.middlewares.use((req: any, res: any, next: (error?: unknown) => void) => {
|
||||
void sendAlignedViteClient(req, res, next, server);
|
||||
});
|
||||
|
||||
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
39
vite-plugins/ws.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user