Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
1944 lines
51 KiB
JavaScript
1944 lines
51 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
writeFileSync
|
|
} from "node:fs";
|
|
import { dirname, relative, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { format } from "prettier";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(__dirname, "../..");
|
|
const protocolRoot = resolve(repoRoot, "packages/events/protocol");
|
|
const protocolSchemasDir = resolve(protocolRoot, "schemas");
|
|
const sharedSchemasDir = resolve(protocolSchemasDir, "topics");
|
|
const coreSchemasDir = resolve(protocolSchemasDir, "core");
|
|
const definitionsDir = resolve(protocolRoot, "definitions");
|
|
const tsGeneratedDir = resolve(protocolRoot, "src/generated");
|
|
const goOutputPath = resolve(
|
|
repoRoot,
|
|
"services/tuttid/api/events/generated/protocol.gen.go"
|
|
);
|
|
const prettierConfigPath = resolve(
|
|
repoRoot,
|
|
"packages/configs/prettier/base.mjs"
|
|
);
|
|
const checkOnly = process.argv.includes("--check");
|
|
|
|
const { default: prettierConfig } = await import(
|
|
pathToFileURL(prettierConfigPath).href
|
|
);
|
|
|
|
const definitionSchemaPath = resolve(
|
|
coreSchemasDir,
|
|
"event-definition.schema.json"
|
|
);
|
|
const envelopeSchemaPath = resolve(
|
|
coreSchemasDir,
|
|
"event-envelope.schema.json"
|
|
);
|
|
const clientFrameSchemaPath = resolve(
|
|
coreSchemasDir,
|
|
"client-frame.schema.json"
|
|
);
|
|
const serverFrameSchemaPath = resolve(
|
|
coreSchemasDir,
|
|
"server-frame.schema.json"
|
|
);
|
|
|
|
const jsonDocumentCache = new Map();
|
|
|
|
const definitionSchema = loadJson(definitionSchemaPath);
|
|
const sharedSchemas = loadSharedSchemas();
|
|
const eventDefinitions = loadEventDefinitions(definitionSchema);
|
|
const envelopeSchema = resolveSchemaRefs(
|
|
loadJson(envelopeSchemaPath),
|
|
envelopeSchemaPath
|
|
);
|
|
const clientFrameSchema = resolveSchemaRefs(
|
|
loadJson(clientFrameSchemaPath),
|
|
clientFrameSchemaPath
|
|
);
|
|
const serverFrameSchema = resolveSchemaRefs(
|
|
loadJson(serverFrameSchemaPath),
|
|
serverFrameSchemaPath
|
|
);
|
|
|
|
validateSupportedSchema(envelopeSchema, "core event envelope schema");
|
|
validateSupportedSchema(clientFrameSchema, "core client frame schema");
|
|
validateSupportedSchema(serverFrameSchema, "core server frame schema");
|
|
|
|
const frameMetadata = buildFrameMetadata(clientFrameSchema, serverFrameSchema);
|
|
const scopeNames = readScopeNames(definitionSchema);
|
|
const directionNames = readDirectionNames(definitionSchema);
|
|
const protocolVersion = readServerProtocolVersion(serverFrameSchema);
|
|
const catalogRevision = buildCatalogRevision({
|
|
eventDefinitions,
|
|
frameMetadata,
|
|
protocolVersion,
|
|
sharedSchemas
|
|
});
|
|
|
|
const generatedFiles = new Map();
|
|
generatedFiles.set(
|
|
resolve(tsGeneratedDir, "index.ts"),
|
|
await formatTypeScript(renderTSIndex())
|
|
);
|
|
generatedFiles.set(
|
|
resolve(tsGeneratedDir, "schemas.ts"),
|
|
await formatTypeScript(
|
|
renderTSSchemas({
|
|
clientFrameSchema,
|
|
envelopeSchema,
|
|
eventDefinitions,
|
|
serverFrameSchema,
|
|
sharedSchemas
|
|
})
|
|
)
|
|
);
|
|
generatedFiles.set(
|
|
resolve(tsGeneratedDir, "contracts.ts"),
|
|
await formatTypeScript(
|
|
renderTSContracts({
|
|
directionNames,
|
|
eventDefinitions,
|
|
frameMetadata,
|
|
protocolVersion,
|
|
scopeNames,
|
|
sharedSchemas
|
|
})
|
|
)
|
|
);
|
|
generatedFiles.set(
|
|
resolve(tsGeneratedDir, "registry.ts"),
|
|
await formatTypeScript(
|
|
renderTSRegistry({
|
|
catalogRevision,
|
|
eventDefinitions
|
|
})
|
|
)
|
|
);
|
|
generatedFiles.set(
|
|
resolve(tsGeneratedDir, "validators.ts"),
|
|
await formatTypeScript(renderTSValidators())
|
|
);
|
|
generatedFiles.set(
|
|
goOutputPath,
|
|
formatGo(
|
|
renderGoProtocol({
|
|
catalogRevision,
|
|
eventDefinitions,
|
|
frameMetadata,
|
|
protocolVersion,
|
|
sharedSchemas
|
|
})
|
|
)
|
|
);
|
|
|
|
for (const [outputPath, source] of generatedFiles) {
|
|
writeGeneratedFile(outputPath, source);
|
|
}
|
|
|
|
function loadSharedSchemas() {
|
|
return collectFiles(sharedSchemasDir, (entry) =>
|
|
entry.endsWith(".schema.json")
|
|
)
|
|
.map((filePath) => {
|
|
const relativePath = relative(sharedSchemasDir, filePath);
|
|
const rawSchema = loadJson(filePath);
|
|
const resolvedSchema = resolveSchemaRefs(rawSchema, filePath);
|
|
validateSupportedSchema(resolvedSchema, `shared schema ${relativePath}`);
|
|
const baseName = relativePath.replace(/\.schema\.json$/, "");
|
|
|
|
return {
|
|
filePath,
|
|
goTypeName: toPascalCase(baseName),
|
|
relativePath,
|
|
resolvedSchema,
|
|
schemaConstantName: `${toCamelCase(baseName)}Schema`,
|
|
tsTypeName: `${toPascalCase(baseName)}V1`
|
|
};
|
|
})
|
|
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
}
|
|
|
|
function loadEventDefinitions(schema) {
|
|
const topics = new Set();
|
|
const topicVersions = new Set();
|
|
|
|
return collectFiles(definitionsDir, (entry) => entry.endsWith(".event.json"))
|
|
.map((filePath) => {
|
|
const relativePath = relative(definitionsDir, filePath);
|
|
const definition = loadJson(filePath);
|
|
const issues = [];
|
|
validateAgainstSchema(definition, schema, "definition", issues);
|
|
if (issues.length > 0) {
|
|
throw new Error(
|
|
`Invalid event definition ${relativePath}: ${formatIssues(issues)}`
|
|
);
|
|
}
|
|
|
|
const topic = definition.topic;
|
|
const topicVersionKey = `${definition.topic}@${definition.version}`;
|
|
if (topics.has(topic)) {
|
|
throw new Error(`Duplicate event topic ${topic} in ${relativePath}`);
|
|
}
|
|
if (topicVersions.has(topicVersionKey)) {
|
|
throw new Error(
|
|
`Duplicate event topic/version ${topicVersionKey} in ${relativePath}`
|
|
);
|
|
}
|
|
topics.add(topic);
|
|
topicVersions.add(topicVersionKey);
|
|
|
|
const payloadSchema = resolveSchemaRefs(
|
|
definition.payloadSchema,
|
|
filePath
|
|
);
|
|
if (!isPlainObject(payloadSchema) || payloadSchema.type !== "object") {
|
|
throw new Error(
|
|
`Event payload schema must resolve to an object in ${relativePath}`
|
|
);
|
|
}
|
|
validateSupportedSchema(payloadSchema, `payload schema for ${topic}`);
|
|
|
|
const baseName = toPascalCase(topic);
|
|
return {
|
|
direction: definition.direction,
|
|
filePath,
|
|
goEventTypeName: `${baseName}Event`,
|
|
goPayloadTypeName: `${baseName}Payload`,
|
|
owner: definition.owner,
|
|
payloadSchema,
|
|
payloadSchemaConstantName: `${toCamelCase(topic)}PayloadSchema`,
|
|
relativePath,
|
|
scope: definition.scope,
|
|
topic,
|
|
topicConstantName: `businessEventTopic${baseName}`,
|
|
tsEventTypeName: `${baseName}EventV1`,
|
|
tsPayloadTypeName: `${baseName}PayloadV1`,
|
|
version: definition.version
|
|
};
|
|
})
|
|
.sort((left, right) => left.topic.localeCompare(right.topic));
|
|
}
|
|
|
|
function buildFrameMetadata(clientSchema, serverSchema) {
|
|
const clientVariants = extractFrameVariants(clientSchema, "client");
|
|
const serverVariants = extractFrameVariants(serverSchema, "server");
|
|
|
|
return {
|
|
clientKinds: clientVariants.map((variant) => variant.kind),
|
|
serverKinds: serverVariants.map((variant) => variant.kind)
|
|
};
|
|
}
|
|
|
|
function extractFrameVariants(schema, label) {
|
|
if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) {
|
|
throw new Error(`Expected ${label} frame schema to use oneOf`);
|
|
}
|
|
|
|
return schema.oneOf.map((variant) => {
|
|
const kind = variant?.properties?.kind?.const;
|
|
if (typeof kind !== "string" || kind.trim() === "") {
|
|
throw new Error(`Expected ${label} frame schema variant to declare kind`);
|
|
}
|
|
return { kind };
|
|
});
|
|
}
|
|
|
|
function readScopeNames(schema) {
|
|
const values = schema?.properties?.scope?.enum;
|
|
if (!Array.isArray(values) || values.length === 0) {
|
|
throw new Error("Expected event definition scope enum");
|
|
}
|
|
return values.map((value) => String(value));
|
|
}
|
|
|
|
function readDirectionNames(schema) {
|
|
const values = schema?.properties?.direction?.enum;
|
|
if (!Array.isArray(values) || values.length === 0) {
|
|
throw new Error("Expected event definition direction enum");
|
|
}
|
|
return values.map((value) => String(value));
|
|
}
|
|
|
|
function readServerProtocolVersion(serverSchema) {
|
|
const readyVariant = serverSchema.oneOf.find(
|
|
(variant) => variant?.properties?.kind?.const === "ready"
|
|
);
|
|
const version = readyVariant?.properties?.protocolVersion?.const;
|
|
if (!Number.isInteger(version)) {
|
|
throw new Error("Expected ready frame protocolVersion const");
|
|
}
|
|
return version;
|
|
}
|
|
|
|
function buildCatalogRevision(input) {
|
|
const hash = createHash("sha256");
|
|
hash.update(
|
|
JSON.stringify(
|
|
{
|
|
clientKinds: input.frameMetadata.clientKinds,
|
|
definitions: input.eventDefinitions.map((definition) => ({
|
|
direction: definition.direction,
|
|
payloadSchema: definition.payloadSchema,
|
|
scope: definition.scope,
|
|
topic: definition.topic,
|
|
version: definition.version
|
|
})),
|
|
protocolVersion: input.protocolVersion,
|
|
serverKinds: input.frameMetadata.serverKinds,
|
|
sharedSchemas: input.sharedSchemas.map((schema) => ({
|
|
name: schema.relativePath,
|
|
schema: schema.resolvedSchema
|
|
}))
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
return `sha256:${hash.digest("hex").slice(0, 16)}`;
|
|
}
|
|
|
|
function renderTSIndex() {
|
|
return `// Code generated by tools/scripts/generate-event-protocol.mjs. DO NOT EDIT.
|
|
|
|
export * from "./contracts.ts";
|
|
export * from "./registry.ts";
|
|
export * from "./validators.ts";
|
|
`;
|
|
}
|
|
|
|
function renderTSSchemas(input) {
|
|
const payloadEntries = input.eventDefinitions
|
|
.map(
|
|
(definition) =>
|
|
` ${JSON.stringify(definition.topic)}: ${definition.payloadSchemaConstantName}`
|
|
)
|
|
.join(",\n");
|
|
|
|
return `// Code generated by tools/scripts/generate-event-protocol.mjs. DO NOT EDIT.
|
|
|
|
${input.sharedSchemas
|
|
.map((schema) =>
|
|
renderSchemaConstant(schema.schemaConstantName, schema.resolvedSchema)
|
|
)
|
|
.join("\n\n")}
|
|
|
|
${input.eventDefinitions
|
|
.map((definition) =>
|
|
renderSchemaConstant(
|
|
definition.payloadSchemaConstantName,
|
|
definition.payloadSchema
|
|
)
|
|
)
|
|
.join("\n\n")}
|
|
|
|
${renderSchemaConstant("businessEventEnvelopeSchema", input.envelopeSchema)}
|
|
|
|
${renderSchemaConstant("businessEventClientFrameSchema", input.clientFrameSchema)}
|
|
|
|
${renderSchemaConstant("businessEventServerFrameSchema", input.serverFrameSchema)}
|
|
|
|
export const businessEventPayloadSchemas = {
|
|
${payloadEntries}
|
|
} as const;
|
|
`;
|
|
}
|
|
|
|
function renderTSContracts(input) {
|
|
const sharedDeclarations = input.sharedSchemas
|
|
.map((schema) =>
|
|
renderTSNamedDeclaration(schema.tsTypeName, schema.resolvedSchema)
|
|
)
|
|
.join("\n\n");
|
|
|
|
const payloadDeclarations = input.eventDefinitions
|
|
.map((definition) =>
|
|
renderTSNamedDeclaration(
|
|
definition.tsPayloadTypeName,
|
|
unresolvedSchemaFromDefinition(definition.filePath).payloadSchema,
|
|
{
|
|
currentFilePath: definition.filePath,
|
|
refTypeNames: buildTSRefTypeNames(input.sharedSchemas)
|
|
}
|
|
)
|
|
)
|
|
.join("\n\n");
|
|
|
|
const eventAliases = input.eventDefinitions
|
|
.map(
|
|
(
|
|
definition
|
|
) => `export type ${definition.tsEventTypeName} = BusinessEventEnvelopeV1<
|
|
${JSON.stringify(definition.topic)},
|
|
${definition.tsPayloadTypeName},
|
|
${definition.version}
|
|
>;`
|
|
)
|
|
.join("\n\n");
|
|
|
|
const clientTopics = input.eventDefinitions
|
|
.filter((definition) => definition.direction === "client->server")
|
|
.map((definition) => JSON.stringify(definition.topic))
|
|
.join(" | ");
|
|
const serverTopics = input.eventDefinitions
|
|
.filter((definition) => definition.direction === "server->client")
|
|
.map((definition) => JSON.stringify(definition.topic))
|
|
.join(" | ");
|
|
|
|
return `// Code generated by tools/scripts/generate-event-protocol.mjs. DO NOT EDIT.
|
|
|
|
export const businessEventProtocolVersion = ${input.protocolVersion} as const;
|
|
|
|
export type BusinessEventDirection = ${input.directionNames
|
|
.map((value) => JSON.stringify(value))
|
|
.join(" | ")};
|
|
|
|
export type BusinessEventScopeName = ${input.scopeNames
|
|
.map((value) => JSON.stringify(value))
|
|
.join(" | ")};
|
|
|
|
export type BusinessEventTopic = ${input.eventDefinitions
|
|
.map((definition) => JSON.stringify(definition.topic))
|
|
.join(" | ")};
|
|
|
|
export interface BusinessEventScopeV1 {
|
|
workspaceId?: string;
|
|
}
|
|
|
|
export interface BusinessEventEnvelopeBaseV1 {
|
|
id: string;
|
|
topic: string;
|
|
version: number;
|
|
emittedAt: string;
|
|
scope?: BusinessEventScopeV1;
|
|
payload: unknown;
|
|
}
|
|
|
|
export type BusinessEventEnvelopeV1<
|
|
TTopic extends string = string,
|
|
TPayload = unknown,
|
|
TVersion extends number = number
|
|
> = Omit<BusinessEventEnvelopeBaseV1, "payload" | "topic" | "version"> & {
|
|
topic: TTopic;
|
|
version: TVersion;
|
|
payload: TPayload;
|
|
};
|
|
|
|
${sharedDeclarations}
|
|
|
|
${payloadDeclarations}
|
|
|
|
${eventAliases}
|
|
|
|
export type ClientToServerEventTopic = ${clientTopics};
|
|
|
|
export type ServerToClientEventTopic = ${serverTopics};
|
|
|
|
export type ClientToServerEventV1 = ${input.eventDefinitions
|
|
.filter((definition) => definition.direction === "client->server")
|
|
.map((definition) => definition.tsEventTypeName)
|
|
.join(" | ")};
|
|
|
|
export type ServerToClientEventV1 = ${input.eventDefinitions
|
|
.filter((definition) => definition.direction === "server->client")
|
|
.map((definition) => definition.tsEventTypeName)
|
|
.join(" | ")};
|
|
|
|
export type BusinessEventV1 = ClientToServerEventV1 | ServerToClientEventV1;
|
|
|
|
export interface BusinessEventSubscribeFrameV1 {
|
|
kind: "subscribe";
|
|
requestId: string;
|
|
topics: readonly ServerToClientEventTopic[];
|
|
scope?: BusinessEventScopeV1;
|
|
}
|
|
|
|
export interface BusinessEventUnsubscribeFrameV1 {
|
|
kind: "unsubscribe";
|
|
requestId: string;
|
|
topics: readonly ServerToClientEventTopic[];
|
|
scope?: BusinessEventScopeV1;
|
|
}
|
|
|
|
export interface BusinessEventPublishFrameV1 {
|
|
kind: "publish";
|
|
requestId: string;
|
|
event: ClientToServerEventV1;
|
|
}
|
|
|
|
export interface BusinessEventPingFrameV1 {
|
|
kind: "ping";
|
|
requestId: string;
|
|
sentAt: string;
|
|
}
|
|
|
|
export type BusinessEventClientFrameV1 =
|
|
| BusinessEventSubscribeFrameV1
|
|
| BusinessEventUnsubscribeFrameV1
|
|
| BusinessEventPublishFrameV1
|
|
| BusinessEventPingFrameV1;
|
|
|
|
export interface BusinessEventReadyFrameV1 {
|
|
kind: "ready";
|
|
protocolVersion: typeof businessEventProtocolVersion;
|
|
catalogRevision: string;
|
|
serverTime: string;
|
|
}
|
|
|
|
export interface BusinessEventAckFrameV1 {
|
|
kind: "ack";
|
|
requestId: string;
|
|
acceptedAt: string;
|
|
}
|
|
|
|
export interface BusinessEventEventFrameV1 {
|
|
kind: "event";
|
|
event: ServerToClientEventV1;
|
|
}
|
|
|
|
export interface BusinessEventErrorFrameV1 {
|
|
kind: "error";
|
|
requestId?: string | null;
|
|
code: string;
|
|
message: string;
|
|
retryable?: boolean;
|
|
}
|
|
|
|
export interface BusinessEventPongFrameV1 {
|
|
kind: "pong";
|
|
requestId: string;
|
|
sentAt: string;
|
|
}
|
|
|
|
export type BusinessEventServerFrameV1 =
|
|
| BusinessEventReadyFrameV1
|
|
| BusinessEventAckFrameV1
|
|
| BusinessEventEventFrameV1
|
|
| BusinessEventErrorFrameV1
|
|
| BusinessEventPongFrameV1;
|
|
`;
|
|
}
|
|
|
|
function renderTSRegistry(input) {
|
|
const definitions = input.eventDefinitions
|
|
.map(
|
|
(definition) => ` {
|
|
topic: ${JSON.stringify(definition.topic)},
|
|
version: ${definition.version},
|
|
direction: ${JSON.stringify(definition.direction)},
|
|
owner: ${JSON.stringify(definition.owner)},
|
|
scope: ${JSON.stringify(definition.scope)}
|
|
}`
|
|
)
|
|
.join(",\n");
|
|
|
|
const byTopic = input.eventDefinitions
|
|
.map(
|
|
(definition) => ` ${JSON.stringify(definition.topic)}: {
|
|
topic: ${JSON.stringify(definition.topic)},
|
|
version: ${definition.version},
|
|
direction: ${JSON.stringify(definition.direction)},
|
|
owner: ${JSON.stringify(definition.owner)},
|
|
scope: ${JSON.stringify(definition.scope)}
|
|
}`
|
|
)
|
|
.join(",\n");
|
|
|
|
return `// Code generated by tools/scripts/generate-event-protocol.mjs. DO NOT EDIT.
|
|
|
|
import type {
|
|
BusinessEventDirection,
|
|
BusinessEventScopeName,
|
|
BusinessEventTopic,
|
|
ClientToServerEventTopic,
|
|
ServerToClientEventTopic
|
|
} from "./contracts.ts";
|
|
|
|
${input.eventDefinitions
|
|
.map(
|
|
(definition) =>
|
|
`export const ${definition.topicConstantName} = ${JSON.stringify(definition.topic)} as const;`
|
|
)
|
|
.join("\n")}
|
|
|
|
export interface BusinessEventDefinition {
|
|
topic: BusinessEventTopic;
|
|
version: number;
|
|
direction: BusinessEventDirection;
|
|
owner: string;
|
|
scope: BusinessEventScopeName;
|
|
}
|
|
|
|
export const businessEventCatalogRevision = ${JSON.stringify(
|
|
input.catalogRevision
|
|
)} as const;
|
|
|
|
export const businessEventDefinitions = [
|
|
${definitions}
|
|
] as const satisfies readonly BusinessEventDefinition[];
|
|
|
|
export const businessEventDefinitionByTopic = {
|
|
${byTopic}
|
|
} as const satisfies Record<BusinessEventTopic, BusinessEventDefinition>;
|
|
|
|
export const businessEventTopics = businessEventDefinitions.map(
|
|
(definition) => definition.topic
|
|
) as readonly BusinessEventTopic[];
|
|
|
|
export const clientToServerEventTopics = businessEventDefinitions
|
|
.filter((definition) => definition.direction === "client->server")
|
|
.map((definition) => definition.topic) as readonly ClientToServerEventTopic[];
|
|
|
|
export const serverToClientEventTopics = businessEventDefinitions
|
|
.filter((definition) => definition.direction === "server->client")
|
|
.map((definition) => definition.topic) as readonly ServerToClientEventTopic[];
|
|
|
|
export function getBusinessEventDefinition(
|
|
topic: string
|
|
): BusinessEventDefinition | null {
|
|
return businessEventDefinitionByTopic[topic as BusinessEventTopic] ?? null;
|
|
}
|
|
|
|
export function isBusinessEventTopic(topic: string): topic is BusinessEventTopic {
|
|
return topic in businessEventDefinitionByTopic;
|
|
}
|
|
|
|
export function isClientToServerEventTopic(
|
|
topic: string
|
|
): topic is ClientToServerEventTopic {
|
|
return clientToServerEventTopics.includes(topic as ClientToServerEventTopic);
|
|
}
|
|
|
|
export function isServerToClientEventTopic(
|
|
topic: string
|
|
): topic is ServerToClientEventTopic {
|
|
return serverToClientEventTopics.includes(topic as ServerToClientEventTopic);
|
|
}
|
|
`;
|
|
}
|
|
|
|
function renderTSValidators() {
|
|
return `// Code generated by tools/scripts/generate-event-protocol.mjs. DO NOT EDIT.
|
|
|
|
import type {
|
|
BusinessEventClientFrameV1,
|
|
BusinessEventServerFrameV1,
|
|
BusinessEventV1
|
|
} from "./contracts.ts";
|
|
import {
|
|
getBusinessEventDefinition,
|
|
isClientToServerEventTopic,
|
|
isServerToClientEventTopic
|
|
} from "./registry.ts";
|
|
import {
|
|
businessEventClientFrameSchema,
|
|
businessEventEnvelopeSchema,
|
|
businessEventPayloadSchemas,
|
|
businessEventServerFrameSchema
|
|
} from "./schemas.ts";
|
|
|
|
export interface BusinessEventProtocolValidationIssue {
|
|
path: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface BusinessEventProtocolValidationResult {
|
|
ok: boolean;
|
|
issues: BusinessEventProtocolValidationIssue[];
|
|
}
|
|
|
|
export function validateEventPayload(
|
|
topic: string,
|
|
value: unknown
|
|
): BusinessEventProtocolValidationResult {
|
|
const schema = businessEventPayloadSchemas[topic as keyof typeof businessEventPayloadSchemas];
|
|
if (!schema) {
|
|
return resultFromIssues([
|
|
{
|
|
path: "payload.topic",
|
|
message: "unknown business event topic"
|
|
}
|
|
]);
|
|
}
|
|
|
|
const issues: BusinessEventProtocolValidationIssue[] = [];
|
|
validateAgainstSchema(value, schema, "payload", issues);
|
|
return resultFromIssues(issues);
|
|
}
|
|
|
|
export function validateEventEnvelope(
|
|
value: unknown
|
|
): BusinessEventProtocolValidationResult {
|
|
return validateEventEnvelopeInternal(value, "event", null);
|
|
}
|
|
|
|
export function validateClientEvent(
|
|
value: unknown
|
|
): BusinessEventProtocolValidationResult {
|
|
return validateEventEnvelopeInternal(value, "event", "client->server");
|
|
}
|
|
|
|
export function validateServerEvent(
|
|
value: unknown
|
|
): BusinessEventProtocolValidationResult {
|
|
return validateEventEnvelopeInternal(value, "event", "server->client");
|
|
}
|
|
|
|
export function validateClientFrame(
|
|
value: unknown
|
|
): BusinessEventProtocolValidationResult {
|
|
const issues: BusinessEventProtocolValidationIssue[] = [];
|
|
validateAgainstSchema(value, businessEventClientFrameSchema, "frame", issues);
|
|
|
|
if (isRecord(value)) {
|
|
if (value.kind === "publish") {
|
|
issues.push(
|
|
...validateClientEvent(value.event).issues.map((issue) => ({
|
|
path: issue.path.replace(/^event/, "frame.event"),
|
|
message: issue.message
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (value.kind === "subscribe" || value.kind === "unsubscribe") {
|
|
validateSubscriptionTopics(value.topics, issues);
|
|
}
|
|
}
|
|
|
|
return resultFromIssues(issues);
|
|
}
|
|
|
|
export function validateServerFrame(
|
|
value: unknown
|
|
): BusinessEventProtocolValidationResult {
|
|
const issues: BusinessEventProtocolValidationIssue[] = [];
|
|
validateAgainstSchema(value, businessEventServerFrameSchema, "frame", issues);
|
|
|
|
if (isRecord(value) && value.kind === "event") {
|
|
issues.push(
|
|
...validateServerEvent(value.event).issues.map((issue) => ({
|
|
path: issue.path.replace(/^event/, "frame.event"),
|
|
message: issue.message
|
|
}))
|
|
);
|
|
}
|
|
|
|
return resultFromIssues(issues);
|
|
}
|
|
|
|
export function assertValidEventEnvelope(
|
|
value: unknown
|
|
): asserts value is BusinessEventV1 {
|
|
assertValidResult(validateEventEnvelope(value));
|
|
}
|
|
|
|
export function assertValidClientFrame(
|
|
value: unknown
|
|
): asserts value is BusinessEventClientFrameV1 {
|
|
assertValidResult(validateClientFrame(value));
|
|
}
|
|
|
|
export function assertValidServerFrame(
|
|
value: unknown
|
|
): asserts value is BusinessEventServerFrameV1 {
|
|
assertValidResult(validateServerFrame(value));
|
|
}
|
|
|
|
export function formatBusinessEventProtocolValidationIssues(
|
|
issues: readonly BusinessEventProtocolValidationIssue[]
|
|
): string {
|
|
if (issues.length === 0) {
|
|
return "business event protocol value is valid";
|
|
}
|
|
|
|
return issues.map((issue) => \`\${issue.path}: \${issue.message}\`).join("; ");
|
|
}
|
|
|
|
function validateEventEnvelopeInternal(
|
|
value: unknown,
|
|
rootPath: string,
|
|
direction: "client->server" | "server->client" | null
|
|
): BusinessEventProtocolValidationResult {
|
|
const issues: BusinessEventProtocolValidationIssue[] = [];
|
|
validateAgainstSchema(value, businessEventEnvelopeSchema, rootPath, issues);
|
|
|
|
if (!isRecord(value)) {
|
|
return resultFromIssues(issues);
|
|
}
|
|
|
|
const topic = typeof value.topic === "string" ? value.topic : null;
|
|
if (!topic) {
|
|
return resultFromIssues(issues);
|
|
}
|
|
|
|
const definition = getBusinessEventDefinition(topic);
|
|
if (!definition) {
|
|
issues.push({
|
|
path: \`\${rootPath}.topic\`,
|
|
message: "unknown business event topic"
|
|
});
|
|
return resultFromIssues(issues);
|
|
}
|
|
|
|
if (value.version !== definition.version) {
|
|
issues.push({
|
|
path: \`\${rootPath}.version\`,
|
|
message: \`event version must be \${definition.version} for topic \${definition.topic}\`
|
|
});
|
|
}
|
|
|
|
if (direction === "client->server" && !isClientToServerEventTopic(topic)) {
|
|
issues.push({
|
|
path: \`\${rootPath}.topic\`,
|
|
message: "topic is not publishable from client to server"
|
|
});
|
|
}
|
|
|
|
if (direction === "server->client" && !isServerToClientEventTopic(topic)) {
|
|
issues.push({
|
|
path: \`\${rootPath}.topic\`,
|
|
message: "topic is not publishable from server to client"
|
|
});
|
|
}
|
|
|
|
issues.push(
|
|
...validateEventPayload(topic, value.payload).issues.map((issue) => ({
|
|
path: issue.path.replace(/^payload/, \`\${rootPath}.payload\`),
|
|
message: issue.message
|
|
}))
|
|
);
|
|
|
|
return resultFromIssues(issues);
|
|
}
|
|
|
|
function validateSubscriptionTopics(
|
|
value: unknown,
|
|
issues: BusinessEventProtocolValidationIssue[]
|
|
) {
|
|
if (!Array.isArray(value)) {
|
|
return;
|
|
}
|
|
|
|
value.forEach((topic, index) => {
|
|
if (typeof topic !== "string") {
|
|
return;
|
|
}
|
|
if (!isServerToClientEventTopic(topic)) {
|
|
issues.push({
|
|
path: \`frame.topics[\${index}]\`,
|
|
message: "clients may only subscribe to server->client topics"
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function validateAgainstSchema(
|
|
value: unknown,
|
|
schema: unknown,
|
|
path: string,
|
|
issues: BusinessEventProtocolValidationIssue[]
|
|
) {
|
|
if (schema === true) {
|
|
return;
|
|
}
|
|
if (!isRecord(schema)) {
|
|
issues.push({ path, message: "invalid generated schema" });
|
|
return;
|
|
}
|
|
|
|
if (schema.const !== undefined) {
|
|
if (value !== schema.const) {
|
|
issues.push({
|
|
path,
|
|
message: \`value must equal \${JSON.stringify(schema.const)}\`
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(schema.oneOf)) {
|
|
const branchErrors = schema.oneOf.map((branch) => {
|
|
const branchIssues: BusinessEventProtocolValidationIssue[] = [];
|
|
validateAgainstSchema(value, branch, path, branchIssues);
|
|
return branchIssues;
|
|
});
|
|
if (!branchErrors.some((entry) => entry.length === 0)) {
|
|
issues.push({
|
|
path,
|
|
message: "value does not match any allowed shape"
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(schema.enum)) {
|
|
if (!schema.enum.some((entry) => entry === value)) {
|
|
issues.push({
|
|
path,
|
|
message: \`value must be one of \${schema.enum
|
|
.map((entry) => JSON.stringify(entry))
|
|
.join(", ")}\`
|
|
});
|
|
}
|
|
}
|
|
|
|
const schemaTypes = normalizeSchemaTypes(schema.type);
|
|
if (schemaTypes.length > 0) {
|
|
const matchedType = schemaTypes.find((type) => matchesSchemaType(value, type));
|
|
if (!matchedType) {
|
|
issues.push({
|
|
path,
|
|
message: \`value must be \${schemaTypes.join(" or ")}\`
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (matchedType === "null") {
|
|
return;
|
|
}
|
|
if (matchedType === "string") {
|
|
validateStringSchema(value, schema, path, issues);
|
|
return;
|
|
}
|
|
if (matchedType === "boolean") {
|
|
return;
|
|
}
|
|
if (matchedType === "integer" || matchedType === "number") {
|
|
validateNumberSchema(value, schema, path, issues);
|
|
return;
|
|
}
|
|
if (matchedType === "array") {
|
|
validateArraySchema(value, schema, path, issues);
|
|
return;
|
|
}
|
|
if (matchedType === "object") {
|
|
validateObjectSchema(value, schema, path, issues);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateStringSchema(
|
|
value: unknown,
|
|
schema: Record<string, unknown>,
|
|
path: string,
|
|
issues: BusinessEventProtocolValidationIssue[]
|
|
) {
|
|
if (typeof value !== "string") {
|
|
return;
|
|
}
|
|
if (
|
|
typeof schema.minLength === "number" &&
|
|
value.length < schema.minLength
|
|
) {
|
|
issues.push({
|
|
path,
|
|
message: \`value must be at least \${schema.minLength} characters\`
|
|
});
|
|
}
|
|
if (typeof schema.pattern === "string" && !(new RegExp(schema.pattern).test(value))) {
|
|
issues.push({
|
|
path,
|
|
message: "value does not match the expected pattern"
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateNumberSchema(
|
|
value: unknown,
|
|
schema: Record<string, unknown>,
|
|
path: string,
|
|
issues: BusinessEventProtocolValidationIssue[]
|
|
) {
|
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
return;
|
|
}
|
|
if (schema.type === "integer" && !Number.isInteger(value)) {
|
|
issues.push({
|
|
path,
|
|
message: "value must be an integer"
|
|
});
|
|
}
|
|
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
|
issues.push({
|
|
path,
|
|
message: \`value must be at least \${schema.minimum}\`
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateArraySchema(
|
|
value: unknown,
|
|
schema: Record<string, unknown>,
|
|
path: string,
|
|
issues: BusinessEventProtocolValidationIssue[]
|
|
) {
|
|
if (!Array.isArray(value)) {
|
|
return;
|
|
}
|
|
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
|
|
issues.push({
|
|
path,
|
|
message: \`value must contain at least \${schema.minItems} items\`
|
|
});
|
|
}
|
|
value.forEach((entry, index) => {
|
|
validateAgainstSchema(entry, schema.items, \`\${path}[\${index}]\`, issues);
|
|
});
|
|
}
|
|
|
|
function validateObjectSchema(
|
|
value: unknown,
|
|
schema: Record<string, unknown>,
|
|
path: string,
|
|
issues: BusinessEventProtocolValidationIssue[]
|
|
) {
|
|
if (!isRecord(value)) {
|
|
return;
|
|
}
|
|
const properties = isRecord(schema.properties) ? schema.properties : {};
|
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
|
|
for (const property of required) {
|
|
if (!(property in value)) {
|
|
issues.push({
|
|
path: \`\${path}.\${property}\`,
|
|
message: "value is required"
|
|
});
|
|
}
|
|
}
|
|
|
|
if (schema.additionalProperties === false) {
|
|
for (const key of Object.keys(value)) {
|
|
if (!(key in properties)) {
|
|
issues.push({
|
|
path: \`\${path}.\${key}\`,
|
|
message: "unexpected property"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [key, propertySchema] of Object.entries(properties)) {
|
|
if (!(key in value)) {
|
|
continue;
|
|
}
|
|
validateAgainstSchema(value[key], propertySchema, \`\${path}.\${key}\`, issues);
|
|
}
|
|
}
|
|
|
|
function normalizeSchemaTypes(value: unknown): string[] {
|
|
if (typeof value === "string") {
|
|
return [value];
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.filter((entry) => typeof entry === "string");
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function matchesSchemaType(value: unknown, type: string): boolean {
|
|
switch (type) {
|
|
case "array":
|
|
return Array.isArray(value);
|
|
case "boolean":
|
|
return typeof value === "boolean";
|
|
case "integer":
|
|
return typeof value === "number" && Number.isInteger(value);
|
|
case "null":
|
|
return value === null;
|
|
case "number":
|
|
return typeof value === "number" && !Number.isNaN(value);
|
|
case "object":
|
|
return isRecord(value);
|
|
case "string":
|
|
return typeof value === "string";
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function resultFromIssues(
|
|
issues: readonly BusinessEventProtocolValidationIssue[]
|
|
): BusinessEventProtocolValidationResult {
|
|
return {
|
|
ok: issues.length === 0,
|
|
issues: [...issues]
|
|
};
|
|
}
|
|
|
|
function assertValidResult(result: BusinessEventProtocolValidationResult) {
|
|
if (!result.ok) {
|
|
throw new Error(
|
|
formatBusinessEventProtocolValidationIssues(result.issues)
|
|
);
|
|
}
|
|
}
|
|
`;
|
|
}
|
|
|
|
function renderGoProtocol(input) {
|
|
const clientTopicConsts = input.eventDefinitions
|
|
.filter((definition) => definition.direction === "client->server")
|
|
.map((definition) => `\t${toGoTopicConstant(definition.topic)},`)
|
|
.join("\n");
|
|
const serverTopicConsts = input.eventDefinitions
|
|
.filter((definition) => definition.direction === "server->client")
|
|
.map((definition) => `\t${toGoTopicConstant(definition.topic)},`)
|
|
.join("\n");
|
|
|
|
return `// Code generated by tools/scripts/generate-event-protocol.mjs. DO NOT EDIT.
|
|
|
|
package generated
|
|
|
|
import "encoding/json"
|
|
|
|
const (
|
|
\tBusinessEventProtocolVersion = ${input.protocolVersion}
|
|
\tBusinessEventCatalogRevision = ${goString(input.catalogRevision)}
|
|
)
|
|
|
|
type Topic string
|
|
|
|
const (
|
|
${input.eventDefinitions
|
|
.map(
|
|
(definition) =>
|
|
`\t${toGoTopicConstant(definition.topic)} Topic = ${goString(definition.topic)}`
|
|
)
|
|
.join("\n")}
|
|
)
|
|
|
|
type Direction string
|
|
|
|
const (
|
|
\tDirectionClientToServer Direction = "client->server"
|
|
\tDirectionServerToClient Direction = "server->client"
|
|
)
|
|
|
|
type ScopeName string
|
|
|
|
const (
|
|
\tScopeNameGlobal ScopeName = "global"
|
|
\tScopeNameDesktop ScopeName = "desktop"
|
|
\tScopeNameWorkspace ScopeName = "workspace"
|
|
)
|
|
|
|
type EventDefinition struct {
|
|
\tTopic Topic
|
|
\tVersion int
|
|
\tDirection Direction
|
|
\tOwner string
|
|
\tScope ScopeName
|
|
}
|
|
|
|
type EventScope struct {
|
|
\tWorkspaceID *string \`json:"workspaceId,omitempty"\`
|
|
}
|
|
|
|
type EventEnvelope struct {
|
|
\tID string \`json:"id"\`
|
|
\tTopic Topic \`json:"topic"\`
|
|
\tVersion int \`json:"version"\`
|
|
\tEmittedAt string \`json:"emittedAt"\`
|
|
\tScope *EventScope \`json:"scope,omitempty"\`
|
|
\tPayload json.RawMessage \`json:"payload"\`
|
|
}
|
|
|
|
${input.sharedSchemas
|
|
.map((schema) =>
|
|
renderGoNamedStruct(schema.goTypeName, schema.resolvedSchema)
|
|
)
|
|
.join("\n\n")}
|
|
|
|
${input.eventDefinitions
|
|
.map((definition) =>
|
|
renderGoNamedStruct(
|
|
definition.goPayloadTypeName,
|
|
unresolvedSchemaFromDefinition(definition.filePath).payloadSchema,
|
|
{
|
|
currentFilePath: definition.filePath
|
|
}
|
|
)
|
|
)
|
|
.join("\n\n")}
|
|
|
|
${input.eventDefinitions
|
|
.map(
|
|
(definition) => `type ${definition.goEventTypeName} struct {
|
|
\tID string \`json:"id"\`
|
|
\tTopic Topic \`json:"topic"\`
|
|
\tVersion int \`json:"version"\`
|
|
\tEmittedAt string \`json:"emittedAt"\`
|
|
\tScope *EventScope \`json:"scope,omitempty"\`
|
|
\tPayload ${definition.goPayloadTypeName} \`json:"payload"\`
|
|
}`
|
|
)
|
|
.join("\n\n")}
|
|
|
|
type ClientSubscribeFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tRequestID string \`json:"requestId"\`
|
|
\tTopics []Topic \`json:"topics"\`
|
|
\tScope *EventScope \`json:"scope,omitempty"\`
|
|
}
|
|
|
|
type ClientUnsubscribeFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tRequestID string \`json:"requestId"\`
|
|
\tTopics []Topic \`json:"topics"\`
|
|
\tScope *EventScope \`json:"scope,omitempty"\`
|
|
}
|
|
|
|
type ClientPublishFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tRequestID string \`json:"requestId"\`
|
|
\tEvent EventEnvelope \`json:"event"\`
|
|
}
|
|
|
|
type ClientPingFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tRequestID string \`json:"requestId"\`
|
|
\tSentAt string \`json:"sentAt"\`
|
|
}
|
|
|
|
type ServerReadyFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tProtocolVersion int \`json:"protocolVersion"\`
|
|
\tCatalogRevision string \`json:"catalogRevision"\`
|
|
\tServerTime string \`json:"serverTime"\`
|
|
}
|
|
|
|
type ServerAckFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tRequestID string \`json:"requestId"\`
|
|
\tAcceptedAt string \`json:"acceptedAt"\`
|
|
}
|
|
|
|
type ServerEventFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tEvent EventEnvelope \`json:"event"\`
|
|
}
|
|
|
|
type ServerErrorFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tRequestID *string \`json:"requestId,omitempty"\`
|
|
\tCode string \`json:"code"\`
|
|
\tMessage string \`json:"message"\`
|
|
\tRetryable *bool \`json:"retryable,omitempty"\`
|
|
}
|
|
|
|
type ServerPongFrame struct {
|
|
\tKind string \`json:"kind"\`
|
|
\tRequestID string \`json:"requestId"\`
|
|
\tSentAt string \`json:"sentAt"\`
|
|
}
|
|
|
|
var BusinessEventDefinitions = []EventDefinition{
|
|
${input.eventDefinitions
|
|
.map(
|
|
(definition) => `\t{
|
|
\t\tTopic: ${toGoTopicConstant(definition.topic)},
|
|
\t\tVersion: ${definition.version},
|
|
\t\tDirection: ${toGoDirectionConstant(definition.direction)},
|
|
\t\tOwner: ${goString(definition.owner)},
|
|
\t\tScope: ${toGoScopeConstant(definition.scope)},
|
|
\t},`
|
|
)
|
|
.join("\n")}
|
|
}
|
|
|
|
var businessEventDefinitionByTopic = map[Topic]EventDefinition{
|
|
${input.eventDefinitions
|
|
.map(
|
|
(definition) =>
|
|
`\t${toGoTopicConstant(definition.topic)}: BusinessEventDefinitions[${input.eventDefinitions.indexOf(definition)}],`
|
|
)
|
|
.join("\n")}
|
|
}
|
|
|
|
var ClientToServerTopics = []Topic{
|
|
${clientTopicConsts}
|
|
}
|
|
|
|
var ServerToClientTopics = []Topic{
|
|
${serverTopicConsts}
|
|
}
|
|
|
|
func LookupEventDefinition(topic Topic) (EventDefinition, bool) {
|
|
\tdefinition, ok := businessEventDefinitionByTopic[topic]
|
|
\treturn definition, ok
|
|
}
|
|
|
|
func IsKnownTopic(topic Topic) bool {
|
|
\t_, ok := businessEventDefinitionByTopic[topic]
|
|
\treturn ok
|
|
}
|
|
|
|
func IsClientToServerTopic(topic Topic) bool {
|
|
\tswitch topic {
|
|
${input.eventDefinitions
|
|
.filter((definition) => definition.direction === "client->server")
|
|
.map(
|
|
(definition) =>
|
|
`\tcase ${toGoTopicConstant(definition.topic)}:\n\t\treturn true`
|
|
)
|
|
.join("\n")}
|
|
\tdefault:
|
|
\t\treturn false
|
|
\t}
|
|
}
|
|
|
|
func IsServerToClientTopic(topic Topic) bool {
|
|
\tswitch topic {
|
|
${input.eventDefinitions
|
|
.filter((definition) => definition.direction === "server->client")
|
|
.map(
|
|
(definition) =>
|
|
`\tcase ${toGoTopicConstant(definition.topic)}:\n\t\treturn true`
|
|
)
|
|
.join("\n")}
|
|
\tdefault:
|
|
\t\treturn false
|
|
\t}
|
|
}
|
|
|
|
func PayloadPrototypeForTopic(topic Topic) (any, bool) {
|
|
\tswitch topic {
|
|
${input.eventDefinitions
|
|
.map(
|
|
(definition) =>
|
|
`\tcase ${toGoTopicConstant(definition.topic)}:\n\t\treturn &${definition.goPayloadTypeName}{}, true`
|
|
)
|
|
.join("\n")}
|
|
\tdefault:
|
|
\t\treturn nil, false
|
|
\t}
|
|
}
|
|
|
|
func EventPrototypeForTopic(topic Topic) (any, bool) {
|
|
\tswitch topic {
|
|
${input.eventDefinitions
|
|
.map(
|
|
(definition) =>
|
|
`\tcase ${toGoTopicConstant(definition.topic)}:\n\t\treturn &${definition.goEventTypeName}{}, true`
|
|
)
|
|
.join("\n")}
|
|
\tdefault:
|
|
\t\treturn nil, false
|
|
\t}
|
|
}
|
|
`;
|
|
}
|
|
|
|
function renderSchemaConstant(name, schema) {
|
|
return `export const ${name} = ${JSON.stringify(schema, null, 2)} as const;`;
|
|
}
|
|
|
|
function renderTSNamedDeclaration(name, schema, options = {}) {
|
|
if (isObjectSchema(schema) && !Array.isArray(schema.oneOf)) {
|
|
return `export interface ${name} ${renderTSObjectLiteral(schema, options, 0)}`;
|
|
}
|
|
return `export type ${name} = ${renderTSTypeExpression(schema, options, 0)};`;
|
|
}
|
|
|
|
function renderTSTypeExpression(schema, options, depth) {
|
|
if (schema === true) {
|
|
return "unknown";
|
|
}
|
|
if (schema?.$ref) {
|
|
const refPath = resolveRefPath(schema.$ref, options.currentFilePath);
|
|
const refTypeName = options.refTypeNames?.get(refPath);
|
|
if (!refTypeName) {
|
|
throw new Error(`Unsupported TypeScript schema reference ${schema.$ref}`);
|
|
}
|
|
return refTypeName;
|
|
}
|
|
if (schema?.const !== undefined) {
|
|
return JSON.stringify(schema.const);
|
|
}
|
|
if (Array.isArray(schema?.enum)) {
|
|
return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
|
|
}
|
|
if (Array.isArray(schema?.oneOf)) {
|
|
return schema.oneOf
|
|
.map((entry) => renderTSTypeExpression(entry, options, depth))
|
|
.join(" | ");
|
|
}
|
|
if (Array.isArray(schema?.type)) {
|
|
return schema.type
|
|
.map((entry) =>
|
|
entry === "null"
|
|
? "null"
|
|
: renderTSTypeExpression({ type: entry }, options, depth)
|
|
)
|
|
.join(" | ");
|
|
}
|
|
|
|
switch (schema?.type) {
|
|
case "array":
|
|
return `readonly (${renderTSTypeExpression(schema.items ?? true, options, depth + 1)})[]`;
|
|
case "boolean":
|
|
return "boolean";
|
|
case "integer":
|
|
case "number":
|
|
return "number";
|
|
case "object":
|
|
return renderTSObjectLiteral(schema, options, depth);
|
|
case "string":
|
|
return "string";
|
|
default:
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
function renderTSObjectLiteral(schema, options, depth) {
|
|
const properties = Object.entries(schema.properties ?? {});
|
|
const required = new Set(schema.required ?? []);
|
|
if (properties.length === 0) {
|
|
if (
|
|
schema.additionalProperties &&
|
|
typeof schema.additionalProperties === "object"
|
|
) {
|
|
return `Record<string, ${renderTSTypeExpression(
|
|
schema.additionalProperties,
|
|
options,
|
|
depth + 1
|
|
)}>`;
|
|
}
|
|
return "Record<string, unknown>";
|
|
}
|
|
|
|
const indent = " ".repeat(depth + 1);
|
|
const closingIndent = " ".repeat(depth);
|
|
const lines = properties.map(([key, propertySchema]) => {
|
|
const propertyName = isValidTSIdentifier(key) ? key : JSON.stringify(key);
|
|
const optional = required.has(key) ? "" : "?";
|
|
const propertyType = renderTSTypeExpression(
|
|
propertySchema,
|
|
options,
|
|
depth + 1
|
|
);
|
|
return `${indent}${propertyName}${optional}: ${propertyType};`;
|
|
});
|
|
|
|
return `{\n${lines.join("\n")}\n${closingIndent}}`;
|
|
}
|
|
|
|
function renderGoNamedStruct(name, schema, options = {}) {
|
|
return `type ${name} struct {
|
|
${renderGoStructFields(schema, 1, options)}
|
|
}`;
|
|
}
|
|
|
|
function renderGoStructFields(schema, depth, options = {}) {
|
|
const properties = Object.entries(schema.properties ?? {});
|
|
const required = new Set(schema.required ?? []);
|
|
const indent = "\t".repeat(depth);
|
|
|
|
return properties
|
|
.map(([key, propertySchema]) => {
|
|
const fieldName = toPascalCase(key);
|
|
const optional = !required.has(key);
|
|
const fieldType = renderGoType(propertySchema, optional, options);
|
|
const omitempty = optional ? ",omitempty" : "";
|
|
return `${indent}${fieldName} ${fieldType} \`json:"${key}${omitempty}"\``;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
function renderGoType(schema, optional, options = {}) {
|
|
if (schema === true) {
|
|
return "any";
|
|
}
|
|
if (schema?.$ref) {
|
|
if (typeof options.currentFilePath !== "string") {
|
|
throw new Error(
|
|
`Missing current file path for Go schema ref ${schema.$ref}`
|
|
);
|
|
}
|
|
const refPath = resolveRefPath(schema.$ref, options.currentFilePath);
|
|
const match = sharedSchemas.find((entry) => entry.filePath === refPath);
|
|
if (!match) {
|
|
throw new Error(`Unsupported Go schema reference ${schema.$ref}`);
|
|
}
|
|
return optional ? `*${match.goTypeName}` : match.goTypeName;
|
|
}
|
|
|
|
const schemaTypes = normalizeSchemaTypes(schema?.type);
|
|
if (schemaTypes.includes("null")) {
|
|
const nextSchema = {
|
|
...schema,
|
|
type: schemaTypes.find((type) => type !== "null")
|
|
};
|
|
return renderGoType(nextSchema, true, options);
|
|
}
|
|
|
|
switch (schema?.type) {
|
|
case "array":
|
|
return `[]${renderGoType(schema.items ?? true, false, options)}`;
|
|
case "boolean":
|
|
return optional ? "*bool" : "bool";
|
|
case "integer":
|
|
if (schema.format === "int64") {
|
|
return optional ? "*int64" : "int64";
|
|
}
|
|
return optional ? "*int" : "int";
|
|
case "number":
|
|
return optional ? "*float64" : "float64";
|
|
case "object":
|
|
if (!isObjectSchema(schema)) {
|
|
return optional ? "*map[string]any" : "map[string]any";
|
|
}
|
|
if (Object.keys(schema.properties ?? {}).length === 0) {
|
|
if (
|
|
schema.additionalProperties &&
|
|
typeof schema.additionalProperties === "object"
|
|
) {
|
|
return `${optional ? "*" : ""}map[string]${renderGoType(
|
|
schema.additionalProperties,
|
|
false,
|
|
options
|
|
)}`;
|
|
}
|
|
return optional ? "*map[string]any" : "map[string]any";
|
|
}
|
|
return optional
|
|
? `*struct {\n${renderGoStructFields(schema, 2, options)}\n\t}`
|
|
: `struct {\n${renderGoStructFields(schema, 2, options)}\n\t}`;
|
|
case "string":
|
|
return optional ? "*string" : "string";
|
|
default:
|
|
return "any";
|
|
}
|
|
}
|
|
|
|
function writeGeneratedFile(outputPath, content) {
|
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
if (checkOnly) {
|
|
if (!existsSync(outputPath)) {
|
|
throw new Error(
|
|
`Missing generated file: ${relative(repoRoot, outputPath)}`
|
|
);
|
|
}
|
|
const existing = readFileSync(outputPath, "utf8");
|
|
if (existing !== content) {
|
|
throw new Error(
|
|
`Generated file is out of date: ${relative(repoRoot, outputPath)}`
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
writeFileSync(outputPath, content, "utf8");
|
|
}
|
|
|
|
function loadJson(filePath) {
|
|
if (jsonDocumentCache.has(filePath)) {
|
|
return jsonDocumentCache.get(filePath);
|
|
}
|
|
const value = JSON.parse(readFileSync(filePath, "utf8"));
|
|
jsonDocumentCache.set(filePath, value);
|
|
return value;
|
|
}
|
|
|
|
function collectFiles(rootDir, predicate) {
|
|
const files = [];
|
|
|
|
walk(rootDir);
|
|
return files;
|
|
|
|
function walk(currentDir) {
|
|
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
const entryPath = resolve(currentDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(entryPath);
|
|
continue;
|
|
}
|
|
if (predicate(entry.name, entryPath)) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveSchemaRefs(value, currentFilePath, stack = []) {
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) =>
|
|
resolveSchemaRefs(entry, currentFilePath, stack)
|
|
);
|
|
}
|
|
if (!isPlainObject(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value.$ref === "string") {
|
|
if (Object.keys(value).length !== 1) {
|
|
throw new Error(
|
|
`Schema refs with sibling keywords are unsupported: ${value.$ref}`
|
|
);
|
|
}
|
|
const refKey = `${currentFilePath}::${value.$ref}`;
|
|
if (stack.includes(refKey)) {
|
|
throw new Error(
|
|
`Circular schema ref detected: ${stack.join(" -> ")} -> ${refKey}`
|
|
);
|
|
}
|
|
const resolved = resolveRef(value.$ref, currentFilePath);
|
|
return resolveSchemaRefs(resolved.value, resolved.filePath, [
|
|
...stack,
|
|
refKey
|
|
]);
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, entry]) => [
|
|
key,
|
|
resolveSchemaRefs(entry, currentFilePath, stack)
|
|
])
|
|
);
|
|
}
|
|
|
|
function resolveRef(ref, currentFilePath) {
|
|
const [filePart, pointerPart = ""] = ref.split("#");
|
|
const filePath =
|
|
filePart.trim() === ""
|
|
? currentFilePath
|
|
: resolve(dirname(currentFilePath), filePart);
|
|
const targetDocument = loadJson(filePath);
|
|
return {
|
|
filePath,
|
|
value:
|
|
pointerPart === ""
|
|
? targetDocument
|
|
: resolveJsonPointer(targetDocument, pointerPart)
|
|
};
|
|
}
|
|
|
|
function resolveJsonPointer(root, pointer) {
|
|
const segments = pointer
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
|
|
let current = root;
|
|
for (const segment of segments) {
|
|
if (!isPlainObject(current) && !Array.isArray(current)) {
|
|
throw new Error(`Unable to resolve JSON pointer segment ${segment}`);
|
|
}
|
|
current = current[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function validateAgainstSchema(value, schema, path, issues) {
|
|
if (schema === true) {
|
|
return;
|
|
}
|
|
if (!isPlainObject(schema)) {
|
|
issues.push({ path, message: "invalid generator schema" });
|
|
return;
|
|
}
|
|
if (schema.const !== undefined) {
|
|
if (value !== schema.const) {
|
|
issues.push({
|
|
path,
|
|
message: `value must equal ${JSON.stringify(schema.const)}`
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (Array.isArray(schema.oneOf)) {
|
|
const branchMatches = schema.oneOf.some((entry) => {
|
|
const branchIssues = [];
|
|
validateAgainstSchema(value, entry, path, branchIssues);
|
|
return branchIssues.length === 0;
|
|
});
|
|
if (!branchMatches) {
|
|
issues.push({ path, message: "value does not match any allowed shape" });
|
|
}
|
|
return;
|
|
}
|
|
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
|
|
issues.push({
|
|
path,
|
|
message: `value must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join(", ")}`
|
|
});
|
|
}
|
|
|
|
const schemaTypes = normalizeSchemaTypes(schema.type);
|
|
if (schemaTypes.length > 0) {
|
|
if (!schemaTypes.some((type) => matchesSchemaType(value, type))) {
|
|
issues.push({
|
|
path,
|
|
message: `value must be ${schemaTypes.join(" or ")}`
|
|
});
|
|
return;
|
|
}
|
|
if (value === null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (typeof schema.pattern === "string" && typeof value === "string") {
|
|
if (!new RegExp(schema.pattern).test(value)) {
|
|
issues.push({
|
|
path,
|
|
message: "value does not match the expected pattern"
|
|
});
|
|
}
|
|
}
|
|
if (typeof schema.minLength === "number" && typeof value === "string") {
|
|
if (value.length < schema.minLength) {
|
|
issues.push({
|
|
path,
|
|
message: `value must be at least ${schema.minLength} characters`
|
|
});
|
|
}
|
|
}
|
|
if (typeof schema.minimum === "number" && typeof value === "number") {
|
|
if (value < schema.minimum) {
|
|
issues.push({
|
|
path,
|
|
message: `value must be at least ${schema.minimum}`
|
|
});
|
|
}
|
|
}
|
|
if (typeof schema.minItems === "number" && Array.isArray(value)) {
|
|
if (value.length < schema.minItems) {
|
|
issues.push({
|
|
path,
|
|
message: `value must contain at least ${schema.minItems} items`
|
|
});
|
|
}
|
|
}
|
|
|
|
if (isPlainObject(value) && schema.type === "object") {
|
|
const properties = isPlainObject(schema.properties)
|
|
? schema.properties
|
|
: {};
|
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
|
|
for (const property of required) {
|
|
if (!(property in value)) {
|
|
issues.push({
|
|
path: `${path}.${property}`,
|
|
message: "value is required"
|
|
});
|
|
}
|
|
}
|
|
|
|
if (schema.additionalProperties === false) {
|
|
for (const key of Object.keys(value)) {
|
|
if (!(key in properties)) {
|
|
issues.push({
|
|
path: `${path}.${key}`,
|
|
message: "unexpected property"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [key, propertySchema] of Object.entries(properties)) {
|
|
if (!(key in value)) {
|
|
continue;
|
|
}
|
|
validateAgainstSchema(
|
|
value[key],
|
|
propertySchema,
|
|
`${path}.${key}`,
|
|
issues
|
|
);
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(value) && schema.type === "array") {
|
|
value.forEach((entry, index) => {
|
|
validateAgainstSchema(
|
|
entry,
|
|
schema.items ?? true,
|
|
`${path}[${index}]`,
|
|
issues
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateSupportedSchema(schema, label) {
|
|
walkSchema(schema, label);
|
|
}
|
|
|
|
function walkSchema(schema, label) {
|
|
if (schema === true) {
|
|
return;
|
|
}
|
|
if (!isPlainObject(schema)) {
|
|
throw new Error(`Unsupported schema node in ${label}`);
|
|
}
|
|
if (schema.$ref !== undefined) {
|
|
throw new Error(`Unresolved schema ref remained in ${label}`);
|
|
}
|
|
if (schema.anyOf !== undefined || schema.allOf !== undefined) {
|
|
throw new Error(`Unsupported schema composition in ${label}`);
|
|
}
|
|
|
|
const schemaTypes = normalizeSchemaTypes(schema.type);
|
|
for (const type of schemaTypes) {
|
|
if (
|
|
![
|
|
"array",
|
|
"boolean",
|
|
"integer",
|
|
"null",
|
|
"number",
|
|
"object",
|
|
"string"
|
|
].includes(type)
|
|
) {
|
|
throw new Error(`Unsupported schema type ${type} in ${label}`);
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(schema.oneOf)) {
|
|
schema.oneOf.forEach((entry, index) =>
|
|
walkSchema(entry, `${label}.oneOf[${index}]`)
|
|
);
|
|
}
|
|
if (isPlainObject(schema.properties)) {
|
|
for (const [key, value] of Object.entries(schema.properties)) {
|
|
walkSchema(value, `${label}.properties.${key}`);
|
|
}
|
|
}
|
|
if (schema.items !== undefined) {
|
|
walkSchema(schema.items, `${label}.items`);
|
|
}
|
|
}
|
|
|
|
function unresolvedSchemaFromDefinition(filePath) {
|
|
return loadJson(filePath);
|
|
}
|
|
|
|
function buildTSRefTypeNames(sharedSchemaEntries) {
|
|
return new Map(
|
|
sharedSchemaEntries.map((entry) => [entry.filePath, entry.tsTypeName])
|
|
);
|
|
}
|
|
|
|
function resolveRefPath(ref, currentFilePath) {
|
|
const [filePart] = ref.split("#");
|
|
if (filePart.trim() === "") {
|
|
return currentFilePath;
|
|
}
|
|
return resolve(dirname(currentFilePath), filePart);
|
|
}
|
|
|
|
function formatIssues(issues) {
|
|
return issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ");
|
|
}
|
|
|
|
function normalizeSchemaTypes(value) {
|
|
if (typeof value === "string") {
|
|
return [value];
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.filter((entry) => typeof entry === "string");
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function matchesSchemaType(value, type) {
|
|
switch (type) {
|
|
case "array":
|
|
return Array.isArray(value);
|
|
case "boolean":
|
|
return typeof value === "boolean";
|
|
case "integer":
|
|
return typeof value === "number" && Number.isInteger(value);
|
|
case "null":
|
|
return value === null;
|
|
case "number":
|
|
return typeof value === "number" && !Number.isNaN(value);
|
|
case "object":
|
|
return isPlainObject(value);
|
|
case "string":
|
|
return typeof value === "string";
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isObjectSchema(schema) {
|
|
return isPlainObject(schema) && schema.type === "object";
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isValidTSIdentifier(value) {
|
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
|
|
}
|
|
|
|
function toPascalCase(value) {
|
|
return value
|
|
.split(/[^A-Za-z0-9]+/)
|
|
.filter(Boolean)
|
|
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
|
|
.join("");
|
|
}
|
|
|
|
function toCamelCase(value) {
|
|
const pascal = toPascalCase(value);
|
|
return pascal[0].toLowerCase() + pascal.slice(1);
|
|
}
|
|
|
|
function toGoTopicConstant(topic) {
|
|
return `Topic${toPascalCase(topic)}`;
|
|
}
|
|
|
|
function toGoDirectionConstant(direction) {
|
|
return direction === "client->server"
|
|
? "DirectionClientToServer"
|
|
: "DirectionServerToClient";
|
|
}
|
|
|
|
function toGoScopeConstant(scope) {
|
|
switch (scope) {
|
|
case "desktop":
|
|
return "ScopeNameDesktop";
|
|
case "global":
|
|
return "ScopeNameGlobal";
|
|
case "workspace":
|
|
return "ScopeNameWorkspace";
|
|
default:
|
|
throw new Error(`Unsupported scope ${scope}`);
|
|
}
|
|
}
|
|
|
|
function goString(value) {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
async function formatTypeScript(source) {
|
|
return await format(source, {
|
|
...prettierConfig,
|
|
parser: "typescript"
|
|
});
|
|
}
|
|
|
|
function formatGo(source) {
|
|
return execFileSync("gofmt", {
|
|
encoding: "utf8",
|
|
input: source
|
|
});
|
|
}
|