Sync OneOS workspace with new prototypes, annotations, and Gitea remote fix.

Add vehicle-h2-fee-ledger, customer-management, lease and self-operated ledgers, annotation sources, agent skills, and vite annotation runtime support. Update vehicle management, contract templates, and lease contract flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-06-30 15:27:23 +08:00
parent 00ca1846af
commit af29b26fe8
309 changed files with 73875 additions and 3838 deletions

View 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);
});
});

View 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)),
},
},
};
},
};
}

View 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')]);
});
});

View 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`;
},
};
}

View File

@@ -17,7 +17,7 @@ import {
} from '../scripts/sync-project-metadata.mjs';
import { MAKE_CONFIG_RELATIVE_PATH } from './utils/makeConstants';
const DEFAULT_ADMIN_ORIGIN = 'http://localhost:5174';
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;
@@ -107,7 +107,7 @@ export function resolveMakeServerStartCommand(
const runtimeOrigin = normalizeOrigin(options.runtimeOrigin);
const cliPath = resolveLocalMakeServerCli(projectRoot);
if (cliPath) {
const args = [cliPath, projectRoot, '--dev'];
const args = [cliPath, projectRoot, '--dev', '--no-open'];
if (runtimeOrigin) {
args.push('--runtime-origin', runtimeOrigin);
}
@@ -117,14 +117,14 @@ export function resolveMakeServerStartCommand(
label: 'local @axhub/make dev',
};
}
const args = ['@axhub/make', projectRoot];
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 @axhub/make',
label: 'npx -y @axhub/make@latest',
};
}
@@ -157,16 +157,17 @@ export async function getReusableAdminOrigin(
}
function isReusableAdminHealth(health: unknown, options: ReusableAdminOriginOptions): boolean {
if (!options.requireDevMode) {
return isReusableRuntimeOrigin(health, options);
}
if (!health || typeof health !== 'object') {
return false;
}
const payload = health as { role?: unknown; devMode?: unknown };
return payload.role === 'admin'
&& payload.devMode === true
&& isReusableRuntimeOrigin(health, options);
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 {

View File

@@ -20,8 +20,10 @@ describe('canvasHotUpdateFilterPlugin', () => {
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/canvas.excalidraw')).toBe(true);
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/canvas-assets/screenshot.png')).toBe(true);
expect(isCanvasHotUpdateFile('src/prototypes/home/canvas-assets/embed.png')).toBe(true);
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/.spec/ai-image-history.json')).toBe(true);
expect(isCanvasHotUpdateFile('/project/src/prototypes/home/.spec/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);
});
@@ -38,15 +40,39 @@ describe('canvasHotUpdateFilterPlugin', () => {
modules: [{ id: 'screenshot' }],
})).toEqual([]);
expect(await handleHotUpdate({
file: '/project/src/prototypes/home/.spec/ai-image-history.json',
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();
@@ -70,6 +96,10 @@ describe('canvasHotUpdateFilterPlugin', () => {
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();
@@ -84,6 +114,69 @@ describe('canvasHotUpdateFilterPlugin', () => {
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',
@@ -95,7 +188,11 @@ describe('canvasHotUpdateFilterPlugin', () => {
})).toBe(true);
expect(shouldDropCanvasFullReloadPayload({
type: 'full-reload',
triggeredBy: '/project/src/prototypes/home/.spec/ai-image-history.json',
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',

View File

@@ -1,19 +1,37 @@
import { normalizePath, type HMRPayload, type Plugin, type ViteDevServer } from 'vite';
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 = normalizePath(filePath);
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;
@@ -27,6 +45,33 @@ function extractPayloadPath(payload: HMRPayload): string | null {
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;
@@ -45,6 +90,13 @@ function patchSend(target: { send?: SendFunction } | null | undefined): void {
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;
}
@@ -66,6 +118,9 @@ export function canvasHotUpdateFilterPlugin(): Plugin {
handleHotUpdate(ctx) {
if (isCanvasHotUpdateFile(ctx.file)) {
if (isAnnotationSourceHotUpdateFile(ctx.file)) {
invalidateHotUpdateModules(ctx);
}
return [];
}
return undefined;

View File

@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Plugin } from 'vite';
import {
@@ -8,6 +9,10 @@ import {
readServerInfo,
} from '../scripts/utils/serverInfo.mjs';
import {
appendProjectIdToModuleSpecifiersInCode,
appendSearchParamToModuleSpecifier,
} from './utils/moduleSpecifierQuery';
import { buildPreviewTitle, readEntryDisplayName } from './utils/previewTitle';
type ResourceType = 'prototypes' | 'themes';
@@ -23,6 +28,15 @@ interface AxhubServerInfo {
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, '\\$&');
@@ -46,21 +60,150 @@ function createPreviewTransformUrl(type: ResourceType, name: string): string {
return createRouteBaseHref(type, name);
}
function normalizeRoute(url: string): { type: ResourceType; name: string; action: 'preview' | 'spec'; assetPath?: string } | null {
const pathname = (url.split('?')[0] || '').replace(/\/index\.html$/u, '').replace(/\.html$/u, '');
const parts = pathname.split('/').filter(Boolean).map((part) => {
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|png|jpe?g|webp|svg)$/iu.test(lastPart);
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] : [];
@@ -69,6 +212,24 @@ function normalizeRoute(url: string): { type: ResourceType; name: string; action
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('/');
@@ -82,6 +243,27 @@ function normalizeRoute(url: string): { type: ResourceType; name: string; action
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);
}
@@ -91,6 +273,29 @@ function readTemplate(projectRoot: string, name: string) {
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) {
@@ -138,11 +343,37 @@ export function injectQuickEditRuntimeScript(html: string, serverOrigin: string
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;
@@ -168,14 +399,158 @@ export function injectPreviewScrollbarStyle(html: string): string {
: `${tag}\n${html}`;
}
function createPreviewLoader(type: ResourceType, name: string, projectRoot: string) {
const importPath = `/${type}/${name}/index.tsx`;
const previewPath = `/${type}/${name}`;
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({
@@ -192,32 +567,52 @@ if (!rootElement) {
}
const root = createRoot(rootElement);
root.render(React.createElement(PreviewComponent, {
container: rootElement,
config: {
projectPath: ${JSON.stringify(projectRoot)},
},
data: {},
events: {},
}));
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;
root.render(React.createElement(NextComponent, {
container: rootElement,
config: {
projectPath: ${JSON.stringify(projectRoot)},
},
data: {},
events: {},
}));
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;
@@ -229,11 +624,23 @@ function sendPreviewFile(res: {
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');
@@ -272,23 +679,39 @@ function isCssModuleRequest(
}
}
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 = path.resolve(projectRoot, 'src', route.type, route.name);
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;
}
if (
assetParts.length > 1
&& (route.type !== 'prototypes' || assetParts[0] !== PROTOTYPE_CANVAS_ASSETS_DIR)
) {
return null;
}
const resolvedPath = path.resolve(resourceDir, ...assetParts);
const relative = path.relative(resourceDir, resolvedPath);
@@ -333,19 +756,25 @@ function createNetworkAdminOriginFromRequestHost(
req: { headers?: Record<string, string | string[] | undefined> },
adminInfo: AxhubServerInfo | null,
): string | null {
if (!adminInfo?.port) {
return null;
}
const hostHeader = getHeaderValue(req.headers?.['x-forwarded-host'] || req.headers?.host).trim();
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 requestHost = new URL(`http://${hostHeader}`).hostname;
if (isLocalHostname(requestHost)) {
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;
}
return `${getRequestProtocol(req)}://${formatUrlHost(requestHost)}:${adminInfo.port}`;
if (!forwardedHostHeader && isLocalHostname(requestHost)) {
return null;
}
return `${getRequestProtocol(req)}://${formatUrlHost(requestHost)}:${port}`;
} catch {
return null;
}
@@ -355,14 +784,21 @@ 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) {
const health = await fetchHealth(embeddedAdminOrigin, 600);
if (isAdminHealthPayload(health) && normalizeHealthServerInfo(health)?.origin) {
if (await isHealthyAdminOrigin(embeddedAdminOrigin)) {
return embeddedAdminOrigin;
}
}
@@ -370,13 +806,20 @@ async function resolveAdminServerOrigin(
const info = readServerInfo(projectRoot, 'admin');
const requestHostAdminOrigin = createNetworkAdminOriginFromRequestHost(req, info);
if (requestHostAdminOrigin) {
const health = await fetchHealth(requestHostAdminOrigin, 600);
if (isAdminHealthPayload(health) && normalizeHealthServerInfo(health)?.origin) {
if (await isHealthyAdminOrigin(requestHostAdminOrigin)) {
return requestHostAdminOrigin;
}
}
return info?.origin || null;
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 {
@@ -385,6 +828,24 @@ export function clientPreviewPlugin(): Plugin {
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 {
@@ -393,24 +854,40 @@ export function clientPreviewPlugin(): Plugin {
return;
}
if (isHtmlProxyModuleRequest(req.url)) {
if (normalizePreviewLoaderRoute(req.url)) {
next();
return;
}
const route = normalizeRoute(req.url);
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 entryPath = path.resolve(projectRoot, 'src', route.type, route.name, 'index.tsx');
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;
@@ -419,7 +896,12 @@ export function clientPreviewPlugin(): Plugin {
type: route.type,
name: route.name,
assetPath: route.assetPath,
resourceDir: previewSource.resourceDir,
});
if (assetPath && requiresViteCssTransform(assetPath)) {
next();
return;
}
if (assetPath && sendPreviewFile(res, assetPath)) {
return;
}
@@ -448,23 +930,24 @@ export function clientPreviewPlugin(): Plugin {
);
html = injectPreviewScrollbarStyle(html);
const stylePath = path.resolve(projectRoot, 'src', route.type, route.name, 'style.css');
const stylePath = previewSource.stylePath;
if (fs.existsSync(stylePath)) {
html = html.replace(
'</head>',
` <link rel="stylesheet" href="/${route.type}/${route.name}/style.css">\n</head>`,
` <link rel="stylesheet" href="${previewSource.styleHref}">\n</head>`,
);
}
html = injectDevTemplateBootstrapScript(html, serverOrigin);
html = html.replace(/\{\{PREVIEW_LOADER\}\}/g, createPreviewLoader(route.type, route.name, projectRoot));
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');
res.end(await server.transformIndexHtml(
const transformedHtml = await server.transformIndexHtml(
createPreviewTransformUrl(route.type, route.name),
html,
));
);
res.end(injectReactRefreshPreambleScript(transformedHtml));
} catch (error) {
next(error);
}

View 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);
}

View File

@@ -21,6 +21,11 @@ type DevServerInfo = {
};
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();
@@ -63,11 +68,95 @@ function writeCurrentDevServerInfo(server: any, startedAt: string): DevServerInf
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();