Files
proxycast/scripts/generate-protocol-types.mjs
T
2026-08-01 00:06:26 +08:00

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