mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 15:34:08 +08:00
481 lines
15 KiB
JavaScript
481 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* 从 Rust JSON Schema bundle 生成 TypeScript 类型定义(自定义转换器,无外部依赖)
|
|
*
|
|
* 输入:lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json
|
|
* 输出:packages/app-server-client/src/generated/protocol-types.ts
|
|
*
|
|
* 规则(参照 internal/refactor/progressive-refactor-plan.md R-10):
|
|
* 1. 生成物头部 // @generated 标记,棘轮按生成代码豁免
|
|
* 2. 生成后 git diff --exit-code 检查漂移(CI 守卫)
|
|
* 3. 方法常量、方法 catalog 和协议类型都从 Rust schema / manifest 生成
|
|
*
|
|
* 用法:
|
|
* node scripts/generate-protocol-types.mjs # 生成
|
|
* node scripts/generate-protocol-types.mjs --check # 检查漂移(CI 用)
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
|
|
const REPO_ROOT = process.cwd();
|
|
const SCHEMA_BUNDLE_PATH = path.join(
|
|
REPO_ROOT,
|
|
"lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json",
|
|
);
|
|
const SCHEMA_MANIFEST_PATH = path.join(
|
|
REPO_ROOT,
|
|
"lime-rs/crates/app-server-protocol/schema/json/manifest.json",
|
|
);
|
|
const METHOD_NAMES_PATHS = [
|
|
path.join(
|
|
REPO_ROOT,
|
|
"lime-rs/crates/app-server-protocol/src/protocol/v2/methods.rs",
|
|
),
|
|
path.join(
|
|
REPO_ROOT,
|
|
"lime-rs/crates/app-server-protocol/src/protocol/v0/method_names.rs",
|
|
),
|
|
];
|
|
const OUTPUT_DIR = path.join(
|
|
REPO_ROOT,
|
|
"packages/app-server-client/src/generated",
|
|
);
|
|
const OUTPUT_PATH = path.join(OUTPUT_DIR, "protocol-types.ts");
|
|
|
|
const GENERATED_HEADER = `// @generated by scripts/generate-protocol-types.mjs — DO NOT EDIT BY HAND
|
|
// Source: lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json
|
|
// Run \`npm run generate:protocol-types\` to regenerate.
|
|
|
|
`;
|
|
|
|
// JSON-RPC 基础类型(envelope 层),不从 schema 生成——它们是手写的稳定接口
|
|
const JSONRPC_TYPE_NAMES = new Set([
|
|
"RequestId",
|
|
"JsonRpcMessage",
|
|
"JsonRpcRequest",
|
|
"JsonRpcNotification",
|
|
"JsonRpcResponse",
|
|
"JsonRpcErrorResponse",
|
|
"JsonRpcError",
|
|
]);
|
|
|
|
// 方法 catalog 等元数据,不是数据类型
|
|
const META_TYPE_NAMES = new Set(["methods"]);
|
|
|
|
// 全局 $defs,供 $ref 解析
|
|
let globalDefs = {};
|
|
|
|
/**
|
|
* 将 JSON Schema 类型映射为 TypeScript 类型字符串
|
|
*/
|
|
function schemaToTs(schema, indent = 0) {
|
|
if (!schema || typeof schema !== "object") return "unknown";
|
|
|
|
// $ref 引用
|
|
if (schema["$ref"]) {
|
|
const ref = schema["$ref"];
|
|
// #/$defs/TypeName → TypeName
|
|
const name = ref.replace(/^#\/\$defs\//, "");
|
|
if (name === "RequestId") {
|
|
return "number | string";
|
|
}
|
|
return name;
|
|
}
|
|
|
|
// oneOf 联合类型
|
|
if (schema.oneOf) {
|
|
const variants = schema.oneOf.map((s) => schemaToTs(s, indent));
|
|
return composeRootSchemaWithVariants(schema, variants, "oneOf", indent);
|
|
}
|
|
|
|
// anyOf 联合类型
|
|
if (schema.anyOf) {
|
|
const variants = schema.anyOf.map((s) => schemaToTs(s, indent));
|
|
return composeRootSchemaWithVariants(schema, variants, "anyOf", indent);
|
|
}
|
|
|
|
// const 字面量
|
|
if (schema.const !== undefined) {
|
|
if (typeof schema.const === "string") return `"${schema.const}"`;
|
|
return String(schema.const);
|
|
}
|
|
|
|
// enum 字面量联合
|
|
if (schema.enum) {
|
|
return schema.enum
|
|
.map((v) => (typeof v === "string" ? `"${v}"` : String(v)))
|
|
.join(" | ");
|
|
}
|
|
|
|
// 联合类型:type: ["array", "null"] 也要保留 items 结构,不能退化成 unknown。
|
|
if (Array.isArray(schema.type)) {
|
|
const variants = schema.type.map((type) =>
|
|
schemaToTs({ ...schema, type }, indent),
|
|
);
|
|
return Array.from(new Set(variants)).join(" | ");
|
|
}
|
|
|
|
// A nullable object preserves properties on the object branch. Check null
|
|
// before the object shortcut so `type: ["object", "null"]` stays nullable.
|
|
if (schema.type === "null") {
|
|
return "null";
|
|
}
|
|
|
|
// 对象类型
|
|
if (schema.type === "object" || schema.properties) {
|
|
return objectTypeToTs(schema, indent);
|
|
}
|
|
|
|
// 数组类型
|
|
if (schema.type === "array") {
|
|
if (schema.items) {
|
|
return `(${schemaToTs(schema.items, indent)})[]`;
|
|
}
|
|
return "unknown[]";
|
|
}
|
|
|
|
// 基本类型
|
|
if (schema.type) {
|
|
return primitiveToTs(schema.type, schema);
|
|
}
|
|
|
|
// 空 schema 或 true(任意类型)
|
|
if (schema === true || Object.keys(schema).length === 0) {
|
|
return "unknown";
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
function composeRootSchemaWithVariants(schema, variants, keyword, indent) {
|
|
const variantType = variants.join(" | ");
|
|
const rootSchema = { ...schema };
|
|
delete rootSchema[keyword];
|
|
const hasRootShape =
|
|
Object.keys(rootSchema.properties || {}).length > 0 ||
|
|
Object.keys(rootSchema.patternProperties || {}).length > 0;
|
|
if (!hasRootShape) {
|
|
return variantType;
|
|
}
|
|
return `(${schemaToTs(rootSchema, indent)}) & (${variantType})`;
|
|
}
|
|
|
|
function primitiveToTs(type, schema = {}) {
|
|
switch (type) {
|
|
case "string":
|
|
return "string";
|
|
case "integer":
|
|
case "number":
|
|
return "number";
|
|
case "boolean":
|
|
return "boolean";
|
|
case "null":
|
|
return "null";
|
|
case "object":
|
|
return "Record<string, unknown>";
|
|
default:
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
function objectTypeToTs(schema, indent = 0) {
|
|
const properties = schema.properties || {};
|
|
const required = new Set(schema.required || []);
|
|
const pad = " ".repeat(indent + 1);
|
|
const closingPad = " ".repeat(indent);
|
|
|
|
const lines = [];
|
|
for (const [name, propSchema] of Object.entries(properties)) {
|
|
const optional = !required.has(name) ? "?" : "";
|
|
const tsType = schemaToTs(propSchema, indent + 1);
|
|
lines.push(`${pad}${name}${optional}: ${tsType};`);
|
|
}
|
|
|
|
if (lines.length === 0) {
|
|
if (schema.additionalProperties === false) {
|
|
return "Record<string, never>";
|
|
}
|
|
return "Record<string, unknown>";
|
|
}
|
|
|
|
return `{\n${lines.join("\n")}\n${closingPad}}`;
|
|
}
|
|
|
|
function generateTypes() {
|
|
assertCompositionSupport();
|
|
console.log("📖 读取 JSON Schema bundle...");
|
|
const bundleRaw = fs.readFileSync(SCHEMA_BUNDLE_PATH, "utf8");
|
|
const bundle = JSON.parse(bundleRaw);
|
|
const manifestRaw = fs.readFileSync(SCHEMA_MANIFEST_PATH, "utf8");
|
|
const manifest = JSON.parse(manifestRaw);
|
|
assertNoHeterogeneousNestedSchemaCollisions(manifest);
|
|
globalDefs = collectSchemaDefinitions(bundle);
|
|
|
|
const defNames = Object.keys(globalDefs);
|
|
console.log(` 找到 ${defNames.length} 个类型定义`);
|
|
|
|
// 处理当前协议类型,跳过 JSON-RPC envelope 和 meta。
|
|
// v0 仍承载非核心产品方法;Thread/Turn 的 current 类型来自 v2。
|
|
const protocolTypeNames = [];
|
|
let skipped = 0;
|
|
for (const name of defNames) {
|
|
if (JSONRPC_TYPE_NAMES.has(name) || META_TYPE_NAMES.has(name)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
protocolTypeNames.push(name);
|
|
}
|
|
console.log(
|
|
` 跳过 ${skipped} 个(JSON-RPC envelope + meta),保留 ${protocolTypeNames.length} 个协议类型`,
|
|
);
|
|
|
|
// 生成 TS 类型定义
|
|
const typeBlocks = [];
|
|
let generated = 0;
|
|
let failed = 0;
|
|
|
|
for (const name of protocolTypeNames) {
|
|
try {
|
|
const schema = globalDefs[name];
|
|
const tsType = schemaToTs(schema, 0);
|
|
// 组合 schema 需要交叉/联合 type;普通 properties 对象使用 interface。
|
|
if (schema.properties && !schema.oneOf && !schema.anyOf) {
|
|
typeBlocks.push(`export interface ${name} ${tsType}`);
|
|
} else {
|
|
typeBlocks.push(`export type ${name} = ${tsType};`);
|
|
}
|
|
generated++;
|
|
} catch (err) {
|
|
console.warn(` ⚠️ 生成 ${name} 失败: ${err.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log(` 生成 ${generated} 个类型,失败 ${failed} 个`);
|
|
|
|
// 组装最终输出
|
|
const requestSerializationScopes = manifest.requestSerializationScopes ?? [];
|
|
const methodConstants = collectMethodConstants(manifest.methods);
|
|
const methodConstantsBlock = methodConstants
|
|
.map(([name, value]) => `export const ${name} = ${JSON.stringify(value)};`)
|
|
.join("\n");
|
|
const methodCatalogBlock = [
|
|
methodConstantsBlock,
|
|
`export const GENERATED_APP_SERVER_METHODS = ${JSON.stringify(
|
|
manifest.methods,
|
|
null,
|
|
2,
|
|
)} as const;`,
|
|
`export type GeneratedAppServerMethodSpec = (typeof GENERATED_APP_SERVER_METHODS)[number];`,
|
|
`export type GeneratedAppServerMethodKind = GeneratedAppServerMethodSpec["kind"];`,
|
|
].join("\n\n");
|
|
const requestSerializationScopeCatalogBlock = [
|
|
`export const GENERATED_APP_SERVER_REQUEST_SERIALIZATION_SCOPES = ${JSON.stringify(
|
|
requestSerializationScopes,
|
|
null,
|
|
2,
|
|
)} as const;`,
|
|
`export type GeneratedAppServerRequestSerializationScopeSpec = (typeof GENERATED_APP_SERVER_REQUEST_SERIALIZATION_SCOPES)[number];`,
|
|
`export type GeneratedAppServerRequestSerializationScope = GeneratedAppServerRequestSerializationScopeSpec["scope"];`,
|
|
].join("\n\n");
|
|
const output =
|
|
GENERATED_HEADER +
|
|
methodCatalogBlock +
|
|
"\n\n" +
|
|
requestSerializationScopeCatalogBlock +
|
|
"\n\n" +
|
|
typeBlocks.join("\n\n") +
|
|
"\n";
|
|
|
|
// 确保输出目录存在
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
fs.writeFileSync(OUTPUT_PATH, output, "utf8");
|
|
formatGeneratedOutput();
|
|
const formattedLength = fs.readFileSync(OUTPUT_PATH, "utf8").length;
|
|
console.log(`✅ 已写入 ${OUTPUT_PATH} (${formattedLength} bytes)`);
|
|
}
|
|
|
|
function assertCompositionSupport() {
|
|
const result = schemaToTs({
|
|
type: "object",
|
|
properties: { threadId: { type: "string" } },
|
|
required: ["threadId"],
|
|
oneOf: [
|
|
{
|
|
type: "object",
|
|
properties: { mode: { const: "form" } },
|
|
required: ["mode"],
|
|
},
|
|
],
|
|
});
|
|
if (
|
|
!result.includes("threadId: string;") ||
|
|
!result.includes('mode: "form";')
|
|
) {
|
|
throw new Error("root properties must survive oneOf/anyOf composition");
|
|
}
|
|
assertNestedSchemaCollisionDetection();
|
|
}
|
|
|
|
function assertNestedSchemaCollisionDetection() {
|
|
const v0 = new Map([
|
|
["Shared", new Set([stableSchemaSignature({ type: "string" })])],
|
|
["Changed", new Set([stableSchemaSignature({ type: "string" })])],
|
|
]);
|
|
const v2 = new Map([
|
|
["Shared", new Set([stableSchemaSignature({ type: "string" })])],
|
|
["Changed", new Set([stableSchemaSignature({ type: "number" })])],
|
|
]);
|
|
const collisions = findHeterogeneousNestedSchemaCollisions(v0, v2);
|
|
if (collisions.length !== 1 || collisions[0] !== "Changed") {
|
|
throw new Error("nested schema collision detection must fail closed");
|
|
}
|
|
}
|
|
|
|
function assertNoHeterogeneousNestedSchemaCollisions(manifest) {
|
|
const v0 = collectNestedSchemaDefinitions("v0", manifest.schemas?.v0 ?? []);
|
|
const v2 = collectNestedSchemaDefinitions("v2", manifest.schemas?.v2 ?? []);
|
|
const collisions = findHeterogeneousNestedSchemaCollisions(v0, v2);
|
|
if (collisions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
[
|
|
`heterogeneous v0/v2 nested schema collisions block flat protocol generation: ${collisions.join(", ")}`,
|
|
"Migrate production consumers and delete the v0 canonical DTOs before regenerating protocol-types.ts.",
|
|
"The flat collector cannot choose a version without corrupting one side of the contract.",
|
|
].join(" "),
|
|
);
|
|
}
|
|
|
|
function collectNestedSchemaDefinitions(group, schemaNames) {
|
|
const definitions = new Map();
|
|
const schemaRoot = path.join(path.dirname(SCHEMA_BUNDLE_PATH), group);
|
|
|
|
for (const schemaName of schemaNames) {
|
|
const schemaPath = path.join(schemaRoot, `${schemaName}.json`);
|
|
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
|
for (const [name, value] of Object.entries(schema.$defs ?? {})) {
|
|
const signatures = definitions.get(name) ?? new Set();
|
|
signatures.add(stableSchemaSignature(value));
|
|
definitions.set(name, signatures);
|
|
}
|
|
}
|
|
|
|
return definitions;
|
|
}
|
|
|
|
function findHeterogeneousNestedSchemaCollisions(v0, v2) {
|
|
return [...v0.keys()]
|
|
.filter((name) => v2.has(name))
|
|
.filter((name) => new Set([...v0.get(name), ...v2.get(name)]).size > 1)
|
|
.sort();
|
|
}
|
|
|
|
function stableSchemaSignature(value) {
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableSchemaSignature).join(",")}]`;
|
|
}
|
|
if (value && typeof value === "object") {
|
|
return `{${Object.keys(value)
|
|
.sort()
|
|
.map(
|
|
(key) => `${JSON.stringify(key)}:${stableSchemaSignature(value[key])}`,
|
|
)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function collectMethodConstants(methods) {
|
|
const entries = METHOD_NAMES_PATHS.flatMap((methodNamesPath) => {
|
|
const source = fs.readFileSync(methodNamesPath, "utf8");
|
|
return [
|
|
...source.matchAll(
|
|
/pub const\s+(METHOD_[A-Z0-9_]+):\s*&str\s*=\s*"([^"]+)";/g,
|
|
),
|
|
].map((match) => [match[1], match[2]]);
|
|
});
|
|
const byMethod = new Map(entries.map(([name, value]) => [value, name]));
|
|
const missing = methods
|
|
.map(({ method }) => method)
|
|
.filter((method) => !byMethod.has(method));
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`method_names.rs missing constants for manifest methods: ${missing.join(", ")}`,
|
|
);
|
|
}
|
|
return methods.map(({ method }) => [byMethod.get(method), method]);
|
|
}
|
|
|
|
function formatGeneratedOutput() {
|
|
execFileSync("npx", ["prettier", "--write", OUTPUT_PATH], {
|
|
cwd: REPO_ROOT,
|
|
stdio: "ignore",
|
|
});
|
|
}
|
|
|
|
function collectSchemaDefinitions(bundle) {
|
|
const collected = { ...(bundle["$defs"] || {}) };
|
|
let nestedAdded = 0;
|
|
|
|
for (const schema of Object.values(collected)) {
|
|
const nestedDefs = schema?.["$defs"];
|
|
if (!nestedDefs || typeof nestedDefs !== "object") {
|
|
continue;
|
|
}
|
|
|
|
for (const [name, nestedSchema] of Object.entries(nestedDefs)) {
|
|
if (Object.hasOwn(collected, name)) {
|
|
continue;
|
|
}
|
|
collected[name] = nestedSchema;
|
|
nestedAdded++;
|
|
}
|
|
}
|
|
|
|
if (nestedAdded > 0) {
|
|
console.log(` 合并 ${nestedAdded} 个内联 $defs 类型定义`);
|
|
}
|
|
|
|
return collected;
|
|
}
|
|
|
|
function checkDrift() {
|
|
if (!fs.existsSync(OUTPUT_PATH)) {
|
|
console.error(`❌ 生成文件不存在: ${OUTPUT_PATH}`);
|
|
console.error(" 请先运行: npm run generate:protocol-types");
|
|
process.exit(1);
|
|
}
|
|
|
|
// 保存当前内容
|
|
const current = fs.readFileSync(OUTPUT_PATH, "utf8");
|
|
|
|
// 重新生成
|
|
generateTypes();
|
|
const regenerated = fs.readFileSync(OUTPUT_PATH, "utf8");
|
|
|
|
// 恢复原内容
|
|
fs.writeFileSync(OUTPUT_PATH, current, "utf8");
|
|
|
|
// 比较
|
|
if (current === regenerated) {
|
|
console.log("✅ 生成文件无漂移。");
|
|
} else {
|
|
console.error("❌ 生成文件与当前版本不一致!存在协议漂移。");
|
|
console.error(" 请运行: npm run generate:protocol-types");
|
|
console.error(" 然后 commit 生成的文件。");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const args = process.argv.slice(2);
|
|
if (args.includes("--check")) {
|
|
checkDrift();
|
|
} else {
|
|
generateTypes();
|
|
}
|