/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ /** * TypeScript code generator for session-events and RPC types. */ import fs from "fs/promises"; import type { JSONSchema7 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import path from "path"; import { fileURLToPath } from "url"; import { getApiSchemaPath, fixNullableRequiredRefsInApiSchema, getNullableInner, getRpcSchemaTypeName, getSessionEventsSchemaPath, postProcessSchema, propagateInternalVisibility, writeGeneratedFile, collectExternalSchemaRefNames, collectDefinitionCollections, collectExperimentalOnlyRpcReferencedDefinitionNames, collectReachableDefinitionNames, collectRpcMethodReferencedDefinitionNames, findSharedSchemaDefinitions, hasSchemaPayload, parseExternalSchemaRef, resolveObjectSchema, resolveSchema, rewriteSharedDefinitionReferences, withSharedDefinitions, isRpcMethod, isNodeFullyExperimental, isNodeFullyDeprecated, isVoidSchema, isSchemaExperimental, isSchemaInternal, appendPropertyMarkerTagsToDescriptions, getEnumValueDescriptions, stripOpaqueJsonMarker, loadSchemaJson, fixBrandCasing, type ApiSchema, type DefinitionCollections, type RpcMethod, } from "./utils.js"; const TS_EXPERIMENTAL_JSDOC = "/** @experimental */"; const EXTERNAL_SCHEMA_TS_IMPORT: Record = { "session-events.schema.json": "./session-events.js", }; function tsExperimentalJSDoc(indent = ""): string { return `${indent}${TS_EXPERIMENTAL_JSDOC}`; } /** * Validates that no public declaration in the generated TypeScript references an internal type. * * If the schema is valid (enforced by the runtime's `assert_no_public_internal_references` lint), * this should never trigger. A failure here means the codegen itself produced a public reference * to an internal type — which is a codegen bug that must be fixed, not silently worked around. */ export function assertNoPublicInternalReferences(generatedTs: string, internalTypes: Set): void { if (internalTypes.size === 0) return; // Identify declarations tagged @internal anywhere in their JSDoc (multi-line or single-line). const internalDeclarations = new Set(); for (const m of generatedTs.matchAll( /\/\*\*(?:[^*]|\*(?!\/))*@internal(?:[^*]|\*(?!\/))*\*\/\s*\nexport (?:interface|type|function|const) (\w+)\b/g )) { internalDeclarations.add(m[1]); } // Split on export interface/type/function/const boundaries for attribution. const declarationRe = /^export (interface|type|function|const) (\w+)\b/gm; const starts: Array<{ index: number; kind: string; name: string }> = []; for (let m = declarationRe.exec(generatedTs); m !== null; m = declarationRe.exec(generatedTs)) { starts.push({ index: m.index, kind: m[1], name: m[2] }); } const blocks = starts.map((start, i) => ({ kind: start.kind, name: start.name, text: generatedTs.slice(start.index, i + 1 < starts.length ? starts[i + 1].index : generatedTs.length), })); const violations: string[] = []; for (const intType of internalTypes) { for (const block of blocks) { if (block.name === intType) continue; if (internalDeclarations.has(block.name)) continue; // Strip content that does not appear in the emitted .d.ts: // 1. All JSDoc/block comments — prevents doc-comment text that happens to name a // type (e.g. "via the definition X") from registering as a code reference. // 2. Function bodies — declaration emit drops bodies, so a reference inside a // function implementation is not a public type reference. // 3. @internal-tagged member sections — TypeScript's stripInternal removes them // from the .d.ts along with any types they reference. let publicText = block.text // Remove @internal-tagged member declarations before stripping comments so // member-level internal references do not count as part of the public surface. // Handles both simple members (`foo?: Hidden;`) and inline object-shaped members // (`foo?: { ... };`) used by generated TypeScript interfaces. .replace( /^[ \t]*\/\*\*[\s\S]*?@internal[\s\S]*?\*\/\s*\n(?:[ \t]*[^\n{;]+;\n?|[ \t]*[^\n{]+\{\n[\s\S]*?^[ \t]*\};\n?)/gm, "" ) // Remove all remaining block comments (JSDoc and otherwise). .replace(/\/\*[\s\S]*?\*\//g, ""); if (block.kind === "function") { // Remove function bodies (from the opening { to matching closing }). publicText = publicText.replace(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, "{}"); } if (new RegExp(`\\b${intType}\\b`).test(publicText)) { violations.push(` ${block.name} (public) references internal type ${intType}`); } } } if (violations.length > 0) { throw new Error( `Codegen produced public declarations that reference internal types.\n` + `This is a codegen bug — fix the generator so internal types are not referenced by public output:\n` + violations.join("\n") ); } } function sanitizeJsDocText(text: string): string { return text.trim().replace(/\*\//g, "* /"); } function tsDocCommentText(text: string): string { const lines = sanitizeJsDocText(text).split(/\r?\n/); if (lines.length === 1) return `/** ${lines[0]} */`; return ["/**", ...lines.map((line) => ` * ${line}`), " */"].join("\n"); } function pushTsJsDoc(lines: string[], indent: string, entries: string[]): void { const cleaned = entries.map(sanitizeJsDocText).filter((entry) => entry.length > 0); if (cleaned.length === 0) return; lines.push(`${indent}/**`); for (const [index, entry] of cleaned.entries()) { if (index > 0) { lines.push(`${indent} *`); } for (const line of entry.split(/\r?\n/)) { lines.push(`${indent} * ${line}`); } } lines.push(`${indent} */`); } function rpcResultDescription(method: RpcMethod): string | undefined { const resultSchema = getMethodResultSchema(method); if (isVoidSchema(resultSchema)) return undefined; return method.result?.description ?? resultSchema?.description; } function rpcParamsDescription(method: RpcMethod, effectiveParams: JSONSchema7 | undefined): string | undefined { return method.params?.description ?? effectiveParams?.description; } function pushTsRpcMethodJsDoc( lines: string[], indent: string, method: RpcMethod, options: { summaryFallback?: string; paramsName?: string; paramsDescription?: string; includeDeprecated?: boolean; includeExperimental?: boolean; } = {} ): void { const entries: string[] = []; entries.push(method.description ?? options.summaryFallback ?? `Calls \`${method.rpcMethod}\`.`); if (options.paramsName && options.paramsDescription) { entries.push(`@param ${options.paramsName} ${options.paramsDescription}`); } const resultDescription = rpcResultDescription(method); if (resultDescription) { entries.push(`@returns ${resultDescription}`); } if (options.includeDeprecated) { entries.push("@deprecated"); } if (options.includeExperimental) { entries.push("@experimental"); } pushTsJsDoc(lines, indent, entries); } function toPascalCase(s: string): string { return fixBrandCasing(s.charAt(0).toUpperCase() + s.slice(1)); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function experimentalDefinitionNames(definitions: DefinitionCollections): Set { const names = new Set(); for (const defs of [definitions.definitions, definitions.$defs]) { for (const [name, def] of Object.entries(defs ?? {})) { if (typeof def === "object" && def !== null && isSchemaExperimental(def as JSONSchema7)) { names.add(name); } } } return names; } function annotateTypeScriptTypes(code: string, typeNames: Iterable, annotation: string): string { let annotated = code; for (const typeName of typeNames) { annotated = annotated.replace( new RegExp(`(^|\\n)(export (?:interface|type|enum) ${escapeRegExp(typeName)}\\b)`, "m"), `$1${annotation}\n$2` ); } return annotated; } function appendUniqueExportBlocks(output: string[], compiled: string, seenBlocks: Map): void { for (const block of splitExportBlocks(compiled)) { const nameMatch = /^export\s+(?:interface|type)\s+(\w+)/m.exec(block); if (!nameMatch) { output.push(block); continue; } const name = nameMatch[1]; const normalizedBlock = normalizeExportBlock(block); const existing = seenBlocks.get(name); if (existing) { if (existing !== normalizedBlock) { throw new Error(`Duplicate generated TypeScript declaration for "${name}" with different content.`); } continue; } seenBlocks.set(name, normalizedBlock); output.push(block); } } function splitExportBlocks(compiled: string): string[] { const normalizedCompiled = compiled .trim() .replace(/;(export\s+(?:interface|type)\s+)/g, ";\n$1") .replace(/}(export\s+(?:interface|type)\s+)/g, "}\n$1"); const lines = normalizedCompiled.split(/\r?\n/); const blocks: string[] = []; let pending: string[] = []; for (let index = 0; index < lines.length;) { const line = lines[index]; if (!/^export\s+(?:interface|type)\s+\w+/.test(line)) { pending.push(line); index++; continue; } const blockLines = [...pending, line]; pending = []; let braceDepth = countBraces(line); index++; if (braceDepth === 0 && line.trim().endsWith(";")) { blocks.push(blockLines.join("\n").trim()); continue; } while (index < lines.length) { const nextLine = lines[index]; blockLines.push(nextLine); braceDepth += countBraces(nextLine); index++; const trimmed = nextLine.trim(); if (braceDepth === 0 && (trimmed === "}" || trimmed.endsWith(";"))) { break; } } blocks.push(blockLines.join("\n").trim()); } return blocks; } function countBraces(line: string): number { let depth = 0; for (const char of line) { if (char === "{") depth++; if (char === "}") depth--; } return depth; } function normalizeExportBlock(block: string): string { return block .replace(/\/\*\*[\s\S]*?\*\//g, "") .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0) .join("\n"); } function collectRpcMethods(node: Record): RpcMethod[] { const results: RpcMethod[] = []; for (const value of Object.values(node)) { if (isRpcMethod(value)) { results.push(value); } else if (typeof value === "object" && value !== null) { results.push(...collectRpcMethods(value as Record)); } } return results; } export function normalizeSchemaForTypeScript(schema: JSONSchema7): JSONSchema7 { const root = structuredClone(schema) as JSONSchema7 & { definitions?: Record; $defs?: Record; }; const definitions = { ...(root.definitions ?? {}) }; const draftDefinitionAliases = new Map(); for (const [key, value] of Object.entries(root.$defs ?? {})) { if (key in definitions) { // The definitions entry is authoritative (it went through the full pipeline). // Drop the $defs duplicate and rewrite any $ref pointing at it to use definitions. draftDefinitionAliases.set(key, key); } else { draftDefinitionAliases.set(key, key); definitions[key] = value; } } root.definitions = definitions; delete root.$defs; const rewrite = (value: unknown): unknown => { if (Array.isArray(value)) { return value.map(rewrite); } if (!value || typeof value !== "object") { return value; } const rewritten = Object.fromEntries( Object.entries(value as Record).map(([key, child]) => [key, rewrite(child)]) ) as Record; // The TypeScript codegen doesn't distinguish opaque JSON from any // other unconstrained value, so drop the marker before feeding the // schema to json-schema-to-typescript. C# codegen reads the marker // from its own (un-normalized) view of the schema and emits // `JsonElement` instead. stripOpaqueJsonMarker(rewritten); const enumValueDescriptions = getEnumValueDescriptions(rewritten as JSONSchema7); if (enumValueDescriptions && Array.isArray(rewritten.enum) && rewritten.enum.every((entry) => typeof entry === "string")) { rewritten.tsType = (rewritten.enum as string[]) .map((entry) => { const comment = enumValueDescriptions[entry]; const literal = JSON.stringify(entry); return comment ? `${tsDocCommentText(comment)}\n| ${literal}` : `| ${literal}`; }) .join("\n"); delete rewritten.type; delete rewritten.enum; delete rewritten["x-enumDescriptions"]; } if (typeof rewritten.$ref === "string") { const externalRef = parseExternalSchemaRef(rewritten.$ref); if (externalRef && EXTERNAL_SCHEMA_TS_IMPORT[externalRef.schemaFile]) { rewritten.tsType = externalRef.definitionName; for (const key of Object.keys(rewritten)) { if (key !== "tsType") { delete rewritten[key]; } } } else if (rewritten.$ref.startsWith("#/$defs/")) { const definitionName = rewritten.$ref.slice("#/$defs/".length); rewritten.$ref = `#/definitions/${draftDefinitionAliases.get(definitionName) ?? definitionName}`; } // json-schema-to-typescript treats sibling keywords alongside $ref as a // new inline type instead of reusing the referenced definition. Strip // siblings so that $ref-only objects compile to a single shared type. if ("$ref" in rewritten) { for (const key of Object.keys(rewritten)) { if (key !== "$ref") delete rewritten[key]; } } } return rewritten; }; return rewrite(root) as JSONSchema7; } // ── Session Events ────────────────────────────────────────────────────────── /** * Filters a `SessionEvent` union schema to exclude internal arms. * * The schema marks internal union members with `visibility: "internal"` on the arm object itself * AND on the resolved definition. An arm is excluded when either level is internal, or when the * arm's resolved `data` property is internal (legacy pattern for event types that carry their * payload in a `data` field). * * Returns the filtered arms and the set of definition names to exclude from compilation. */ export function filterPublicSessionEventVariants( variants: JSONSchema7[], definitionCollections: DefinitionCollections ): { publicVariants: JSONSchema7[]; excludedDefinitionNames: Set } { const excludedDefinitionNames = new Set(); const publicVariants = variants.filter((variant) => { const variantSchema = variant as JSONSchema7; const resolvedVariant = resolveSchema(variantSchema, definitionCollections) ?? variantSchema; // Exclude the arm if the arm object itself or its resolved definition is internal. // The schema marks internal union members at both levels; checking only the resolved // definition's `data` sub-property (the original logic) missed cases where the event // type itself carries `visibility: "internal"`. if (isSchemaInternal(variantSchema) || isSchemaInternal(resolvedVariant)) { for (const ref of [variantSchema.$ref]) { const match = ref?.match(/^#\/(?:definitions|\$defs)\/([^/]+)$/); if (match) excludedDefinitionNames.add(match[1]); } return false; } const dataSchema = resolvedVariant.properties?.data as JSONSchema7 | undefined; const resolvedData = dataSchema ? resolveSchema(dataSchema, definitionCollections) ?? dataSchema : undefined; if (!isSchemaInternal(resolvedData)) { return true; } for (const ref of [variantSchema.$ref, dataSchema?.$ref]) { const match = ref?.match(/^#\/(?:definitions|\$defs)\/([^/]+)$/); if (match) excludedDefinitionNames.add(match[1]); } return false; }); return { publicVariants, excludedDefinitionNames }; } async function generateSessionEvents(schemaPath?: string): Promise { console.log("TypeScript: generating session-events..."); const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); const schema = (await loadSchemaJson(resolvedPath)) as JSONSchema7; const processed = propagateInternalVisibility(postProcessSchema(schema)); const definitionCollections = collectDefinitionCollections(processed as Record); const sessionEvent = resolveSchema({ $ref: "#/definitions/SessionEvent" }, definitionCollections) ?? resolveSchema({ $ref: "#/$defs/SessionEvent" }, definitionCollections) ?? processed; const { publicVariants, excludedDefinitionNames } = filterPublicSessionEventVariants( sessionEvent.anyOf ?? [], definitionCollections ); const publicDefinitions = Object.fromEntries( Object.entries(definitionCollections.definitions).filter(([name]) => !excludedDefinitionNames.has(name)) ); const publicDraftDefinitions = Object.fromEntries( Object.entries(definitionCollections.$defs).filter(([name]) => !excludedDefinitionNames.has(name)) ); const publicSessionEvent = { ...sessionEvent, anyOf: publicVariants }; if ("SessionEvent" in publicDefinitions) { publicDefinitions.SessionEvent = publicSessionEvent; } if ("SessionEvent" in publicDraftDefinitions) { publicDraftDefinitions.SessionEvent = publicSessionEvent; } const schemaForCompile = withSharedDefinitions( publicSessionEvent, { definitions: publicDefinitions, $defs: publicDraftDefinitions } ); appendPropertyMarkerTagsToDescriptions(schemaForCompile); const ts = await compile(normalizeSchemaForTypeScript(schemaForCompile), "SessionEvent", { bannerComment: `/** * AUTO-GENERATED FILE - DO NOT EDIT * Generated from: session-events.schema.json */`, style: { semi: true, singleQuote: false, trailingComma: "all" }, additionalProperties: false, strictIndexSignatures: true, }); let annotatedTs = annotateTypeScriptTypes(ts, experimentalDefinitionNames(definitionCollections), TS_EXPERIMENTAL_JSDOC); // Add @internal JSDoc annotations for session-event types marked // `visibility: "internal"` in the schema. The tag drives `stripInternal` // so the whole type is dropped from the published .d.ts. // Because internal union arms are excluded from the compiled output by the // publicVariants filter above, no public declaration should reference these // types; assertNoPublicInternalReferences enforces that invariant hard. const sessionInternalTypes = new Set(); for (const [name, def] of Object.entries(definitionCollections.definitions ?? {})) { if (def && typeof def === "object" && (def as Record).visibility === "internal") { sessionInternalTypes.add(name); } } for (const [name, def] of Object.entries(definitionCollections.$defs ?? {})) { if (def && typeof def === "object" && (def as Record).visibility === "internal") { sessionInternalTypes.add(name); } } for (const intType of sessionInternalTypes) { annotatedTs = annotatedTs.replace( new RegExp(`(^|\\n)(export (?:interface|type) ${intType}\\b)`, "m"), `$1/** @internal */\n$2` ); } assertNoPublicInternalReferences(annotatedTs, sessionInternalTypes); const outPath = await writeGeneratedFile("nodejs/src/generated/session-events.ts", annotatedTs); console.log(` ✓ ${outPath}`); } // ── RPC Types ─────────────────────────────────────────────────────────────── let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; function withRootTitle(schema: JSONSchema7, title: string): JSONSchema7 { return { ...schema, title }; } function rpcRequestFallbackName(method: RpcMethod): string { return method.rpcMethod.split(".").map(toPascalCase).join("") + "Request"; } function schemaSourceForNamedDefinition( schema: JSONSchema7 | null | undefined, resolvedSchema: JSONSchema7 | undefined ): JSONSchema7 { if (schema?.$ref && resolvedSchema) { return resolvedSchema; } // When the schema is an anyOf/oneOf wrapper (e.g., Zod optional params producing // `anyOf: [{ not: {} }, { $ref }]`), use the resolved object schema to avoid // generating self-referential type aliases. if ((schema?.anyOf || schema?.oneOf) && resolvedSchema?.properties) { return resolvedSchema; } return schema ?? resolvedSchema ?? { type: "object" }; } function getMethodResultSchema(method: RpcMethod): JSONSchema7 | undefined { return resolveSchema(method.result, rpcDefinitions) ?? method.result ?? undefined; } function getMethodParamsSchema(method: RpcMethod): JSONSchema7 | undefined { return ( resolveObjectSchema(method.params, rpcDefinitions) ?? resolveSchema(method.params, rpcDefinitions) ?? method.params ?? undefined ); } /** True when the raw params schema uses `anyOf: [{ not: {} }, …]` — Zod's pattern for `.optional()`. */ function isParamsOptional(method: RpcMethod): boolean { const schema = method.params; if (!schema?.anyOf) return false; return schema.anyOf.some( (item) => typeof item === "object" && (item as JSONSchema7).not !== undefined && typeof (item as JSONSchema7).not === "object" && Object.keys((item as JSONSchema7).not as object).length === 0 ); } function resultTypeName(method: RpcMethod): string { const schema = getMethodResultSchema(method); const externalRef = schema?.$ref ? parseExternalSchemaRef(schema.$ref) : undefined; return externalRef?.definitionName ?? getRpcSchemaTypeName(schema, method.rpcMethod.split(".").map(toPascalCase).join("") + "Result"); } function tsNullableResultTypeName(method: RpcMethod): string | undefined { const resultSchema = getMethodResultSchema(method); if (!resultSchema) return undefined; const inner = getNullableInner(resultSchema); if (!inner) return undefined; // Resolve $ref to a type name if (inner.$ref) { const refName = inner.$ref.split("/").pop(); if (refName) return `${toPascalCase(refName)} | undefined`; } const innerName = getRpcSchemaTypeName(inner, method.rpcMethod.split(".").map(toPascalCase).join("") + "Result"); return `${innerName} | undefined`; } function tsResultType(method: RpcMethod): string { if (isVoidSchema(getMethodResultSchema(method))) return "void"; return tsNullableResultTypeName(method) ?? resultTypeName(method); } function paramsTypeName(method: RpcMethod): string { const fallback = rpcRequestFallbackName(method); if (method.rpcMethod.startsWith("session.") && method.params?.$ref) { return fallback; } const schema = getMethodParamsSchema(method); const externalRef = schema?.$ref ? parseExternalSchemaRef(schema.$ref) : undefined; return externalRef?.definitionName ?? getRpcSchemaTypeName(schema, fallback); } async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema7): Promise { console.log("TypeScript: generating RPC types..."); const resolvedPath = schemaPath ?? (await getApiSchemaPath()); let schema = fixNullableRequiredRefsInApiSchema((await loadSchemaJson(resolvedPath)) as ApiSchema); if (sessionEventsSchema) { const sharedDefinitions = findSharedSchemaDefinitions( schema as unknown as Record, sessionEventsSchema as unknown as Record ); const reachableDefinitions = collectReachableDefinitionNames(sessionEventsSchema as unknown as Record); for (const name of [...sharedDefinitions]) { if (!reachableDefinitions.has(name)) { sharedDefinitions.delete(name); } } schema = rewriteSharedDefinitionReferences(schema, sharedDefinitions, "session-events.schema.json"); } const lines: string[] = []; lines.push(`/** * AUTO-GENERATED FILE - DO NOT EDIT * Generated from: api.schema.json */ import type { MessageConnection } from "vscode-jsonrpc/node.js"; `); const externalSchemaRefs = collectExternalSchemaRefNames(schema); for (const [schemaFile, typeNames] of externalSchemaRefs) { const importPath = EXTERNAL_SCHEMA_TS_IMPORT[schemaFile]; if (importPath) { lines.push(`import type { ${[...typeNames].sort().join(", ")} } from "${importPath}";`); } } if (externalSchemaRefs.size > 0) { lines.push(""); } const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; const clientSessionMethods = collectRpcMethods(schema.clientSession || {}); const clientGlobalMethods = collectRpcMethods(schema.clientGlobal || {}); const rpcMethods = [...allMethods, ...clientSessionMethods, ...clientGlobalMethods]; const seenBlocks = new Map(); // Build a single combined schema with shared definitions and all method types. // This ensures $ref-referenced types are generated exactly once. rpcDefinitions = collectDefinitionCollections(schema as Record); const combinedSchema = withSharedDefinitions( { $schema: "http://json-schema.org/draft-07/schema#", type: "object", }, rpcDefinitions ); // Track which type names come from experimental methods for JSDoc annotations. const experimentalTypes = experimentalDefinitionNames(collectDefinitionCollections(combinedSchema as Record)); for (const name of collectExperimentalOnlyRpcReferencedDefinitionNames(rpcMethods, rpcDefinitions)) { experimentalTypes.add(name); } const nonExperimentalReferencedTypes = collectRpcMethodReferencedDefinitionNames( rpcMethods.filter((method) => method.stability !== "experimental"), rpcDefinitions ); // Track which type names come from deprecated methods for JSDoc annotations. const deprecatedTypes = new Set(); // Types are tagged @internal directly via `visibility: "internal"` on the JSON Schema // definition (set by `.asInternal()` on the originating Zod schema). The runtime // schema generator enforces that no public method references an internal type, so // there's no transitive propagation to do here. const internalTypes = new Set(); for (const [name, def] of Object.entries(combinedSchema.definitions ?? {})) { if (def && typeof def === "object" && (def as Record).visibility === "internal") { internalTypes.add(name); } } for (const method of rpcMethods) { const resultSchema = getMethodResultSchema(method); const resultExternalRef = method.result?.$ref ? parseExternalSchemaRef(method.result.$ref) : undefined; if (!resultExternalRef && !isVoidSchema(resultSchema) && !getNullableInner(resultSchema)) { const resultSource = schemaSourceForNamedDefinition(method.result, resultSchema); combinedSchema.definitions![resultTypeName(method)] = withRootTitle( resultSource, resultTypeName(method) ); if (isSchemaExperimental(resultSource) || (method.stability === "experimental" && !nonExperimentalReferencedTypes.has(resultTypeName(method)))) { experimentalTypes.add(resultTypeName(method)); } if (method.deprecated && !method.result?.$ref) { deprecatedTypes.add(resultTypeName(method)); } } const resolvedParams = getMethodParamsSchema(method); if (method.params && hasSchemaPayload(resolvedParams)) { const paramsExternalRef = method.params.$ref ? parseExternalSchemaRef(method.params.$ref) : undefined; if (paramsExternalRef) { continue; } if (method.rpcMethod.startsWith("session.") && resolvedParams?.properties) { const filtered: JSONSchema7 = { ...resolvedParams, properties: Object.fromEntries( Object.entries(resolvedParams.properties).filter(([k]) => k !== "sessionId") ), required: resolvedParams.required?.filter((r) => r !== "sessionId"), }; if (hasSchemaPayload(filtered)) { combinedSchema.definitions![paramsTypeName(method)] = withRootTitle( filtered, paramsTypeName(method) ); if (isSchemaExperimental(filtered) || (method.stability === "experimental" && !nonExperimentalReferencedTypes.has(paramsTypeName(method)))) { experimentalTypes.add(paramsTypeName(method)); } if (method.deprecated) { deprecatedTypes.add(paramsTypeName(method)); } } } else { const paramsSource = schemaSourceForNamedDefinition(method.params, resolvedParams); combinedSchema.definitions![paramsTypeName(method)] = withRootTitle( paramsSource, paramsTypeName(method) ); if (isSchemaExperimental(paramsSource) || (method.stability === "experimental" && !nonExperimentalReferencedTypes.has(paramsTypeName(method)))) { experimentalTypes.add(paramsTypeName(method)); } if (method.deprecated && !method.params?.$ref) { deprecatedTypes.add(paramsTypeName(method)); } } } } const schemaForCompile = combinedSchema; appendPropertyMarkerTagsToDescriptions(schemaForCompile); const compiled = await compile(normalizeSchemaForTypeScript(schemaForCompile), "_RpcSchemaRoot", { bannerComment: "", additionalProperties: false, strictIndexSignatures: true, unreachableDefinitions: true, }); // Strip the placeholder root type and keep only the definition-generated types const strippedTs = compiled .replace( /\/\*\*\n \* This (?:interface|type) was referenced by `_RpcSchemaRoot`'s JSON-Schema\n \* via the `definition` "[^"]+"\.\n \*\/\n/g, "\n" ) .replace(/export interface _RpcSchemaRoot\s*\{[^}]*\}\s*/g, "") .replace(/export type _RpcSchemaRoot = [^;]+;\s*/g, "") .trim(); if (strippedTs) { // Add @experimental JSDoc annotations for types from experimental methods or schemas. let annotatedTs = annotateTypeScriptTypes(strippedTs, experimentalTypes, TS_EXPERIMENTAL_JSDOC); // Add @deprecated JSDoc annotations for types from deprecated methods for (const depType of deprecatedTypes) { annotatedTs = annotatedTs.replace( new RegExp(`(^|\\n)(export (?:interface|type) ${depType}\\b)`, "m"), `$1/** @deprecated */\n$2` ); } // @internal tagging happens in a final pass over the assembled file: the client/server // method signatures that reference these types are emitted later, so a per-chunk check // would not see them and would strip a type the public API still names. lines.push(annotatedTs); lines.push(""); } // Generate factory functions function hasInternalMethods(node: Record): boolean { for (const value of Object.values(node)) { if (isRpcMethod(value)) { if ((value as RpcMethod).visibility === "internal") return true; } else if (typeof value === "object" && value !== null) { if (hasInternalMethods(value as Record)) return true; } } return false; } if (schema.server) { lines.push(`/** Create typed server-scoped RPC methods (no session required). */`); lines.push(`export function createServerRpc(connection: MessageConnection) {`); lines.push(` return {`); lines.push(...emitGroup(schema.server, " ", false, false, false, "public")); lines.push(` };`); lines.push(`}`); lines.push(""); if (hasInternalMethods(schema.server)) { lines.push(`/**`); lines.push(` * Create typed server-scoped RPC methods that are part of the SDK's internal`); lines.push(` * surface (e.g. handshake helpers). Not exported on the public client API.`); lines.push(` * @internal`); lines.push(` */`); lines.push(`export function createInternalServerRpc(connection: MessageConnection) {`); lines.push(` return {`); lines.push(...emitGroup(schema.server, " ", false, false, false, "internal")); lines.push(` };`); lines.push(`}`); lines.push(""); } } if (schema.session) { lines.push(`/** Create typed session-scoped RPC methods. */`); lines.push(`export function createSessionRpc(connection: MessageConnection, sessionId: string) {`); lines.push(` return {`); lines.push(...emitGroup(schema.session, " ", true, false, false, "public")); lines.push(` };`); lines.push(`}`); lines.push(""); if (hasInternalMethods(schema.session)) { lines.push(`/**`); lines.push(` * Create typed session-scoped RPC methods that are part of the SDK's internal`); lines.push(` * surface. Not exported on the public client API.`); lines.push(` * @internal`); lines.push(` */`); lines.push(`export function createInternalSessionRpc(connection: MessageConnection, sessionId: string) {`); lines.push(` return {`); lines.push(...emitGroup(schema.session, " ", true, false, false, "internal")); lines.push(` };`); lines.push(`}`); lines.push(""); } } // Generate client session API handler interfaces and registration function if (schema.clientSession) { lines.push(...emitClientSessionApiRegistration(schema.clientSession)); } // Generate client *global* API handler interfaces and registration function. // Unlike client-session APIs, these methods do not carry a `sessionId` dispatch // key — the SDK consumer registers a single process-wide handler per group. if (schema.clientGlobal) { lines.push(...emitClientGlobalApiRegistration(schema.clientGlobal)); } // Apply @internal to RPC types in a final pass over the assembled file. // The client/server method signatures that reference these types are emitted // after the per-schema type chunks, so the tagging must happen here rather // than per-chunk. assertNoPublicInternalReferences then enforces hard that no // public declaration slipped through referencing a type the schema marked internal. let rpcTs = lines.join("\n"); for (const intType of internalTypes) { rpcTs = rpcTs.replace( new RegExp(`(^|\\n)(export (?:interface|type) ${intType}\\b)`, "m"), `$1/** @internal */\n$2` ); } assertNoPublicInternalReferences(rpcTs, internalTypes); const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", rpcTs); console.log(` ✓ ${outPath}`); } function emitGroup( node: Record, indent: string, isSession: boolean, parentExperimental = false, parentDeprecated = false, visibilityFilter?: "public" | "internal", ): string[] { const lines: string[] = []; for (const [key, value] of Object.entries(node)) { if (isRpcMethod(value)) { const isInternalMethod = (value as RpcMethod).visibility === "internal"; if (visibilityFilter === "public" && isInternalMethod) continue; if (visibilityFilter === "internal" && !isInternalMethod) continue; const { rpcMethod, params } = value; const resultType = tsResultType(value); const paramsType = paramsTypeName(value); const effectiveParams = getMethodParamsSchema(value); const paramEntries = effectiveParams?.properties ? Object.entries(effectiveParams.properties).filter(([k]) => k !== "sessionId") : []; const hasParams = hasSchemaPayload(effectiveParams); const hasNonSessionParams = paramEntries.length > 0; const sigParams: string[] = []; let bodyArg: string; if (isSession) { if (hasNonSessionParams) { const optMark = isParamsOptional(value) ? "?" : ""; // sessionId is already stripped from the generated type definition, // so no need for Omit<..., "sessionId"> sigParams.push(`params${optMark}: ${paramsType}`); bodyArg = "{ sessionId, ...params }"; } else { bodyArg = "{ sessionId }"; } } else { if (hasParams) { const optMark = isParamsOptional(value) ? "?" : ""; sigParams.push(`params${optMark}: ${paramsType}`); bodyArg = "params"; } else { bodyArg = "{}"; } } pushTsRpcMethodJsDoc(lines, indent, value, { paramsName: sigParams.length > 0 ? "params" : undefined, paramsDescription: rpcParamsDescription(value, effectiveParams), includeDeprecated: (value as RpcMethod).deprecated && !parentDeprecated, includeExperimental: (value as RpcMethod).stability === "experimental" && !parentExperimental, }); lines.push(`${indent}${key}: async (${sigParams.join(", ")}): Promise<${resultType}> =>`); lines.push(`${indent} connection.sendRequest("${rpcMethod}", ${bodyArg}),`); } else if (typeof value === "object" && value !== null) { const groupExperimental = isNodeFullyExperimental(value as Record); const groupDeprecated = isNodeFullyDeprecated(value as Record); const childLines = emitGroup( value as Record, indent + " ", isSession, groupExperimental, groupDeprecated, visibilityFilter, ); // Skip the wrapper if the visibility filter dropped every method in this subtree. if (childLines.length === 0) continue; if (groupDeprecated) { lines.push(`${indent}/** @deprecated */`); } if (groupExperimental) { lines.push(tsExperimentalJSDoc(indent)); } lines.push(`${indent}${key}: {`); lines.push(...childLines); lines.push(`${indent}},`); } } return lines; } // ── Client Session API Handler Generation ─────────────────────────────────── /** * Collect client API methods grouped by their top-level namespace. * Returns a map like: { sessionFs: [{ rpcMethod, params, result }, ...] } */ function collectClientGroups(node: Record): Map { const groups = new Map(); for (const [groupName, groupNode] of Object.entries(node)) { if (typeof groupNode === "object" && groupNode !== null) { groups.set(groupName, collectRpcMethods(groupNode as Record)); } } return groups; } /** * Derive the handler method name from the full RPC method name. * e.g., "sessionFs.readFile" → "readFile" */ function handlerMethodName(rpcMethod: string): string { const parts = rpcMethod.split("."); return parts[parts.length - 1]; } /** * Generate handler interfaces and a registration function for client session API groups. * * Client session API methods have `sessionId` on the wire (injected by the * runtime's proxy layer). The generated registration function accepts a * `getHandler` callback that resolves a sessionId to a handler object. * Param types include sessionId — handler code can simply ignore it. */ function emitClientSessionApiRegistration(clientSchema: Record): string[] { const lines: string[] = []; const groups = collectClientGroups(clientSchema); // Emit a handler interface per group for (const [groupName, methods] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; const groupDeprecated = isNodeFullyDeprecated(clientSchema[groupName] as Record); const groupExperimental = isNodeFullyExperimental(clientSchema[groupName] as Record); if (groupDeprecated) { lines.push(`/** @deprecated Handler for \`${groupName}\` client session API methods. */`); } else if (groupExperimental) { lines.push(`/** Handler for \`${groupName}\` client session API methods. */`); lines.push(TS_EXPERIMENTAL_JSDOC); } else { lines.push(`/** Handler for \`${groupName}\` client session API methods. */`); } lines.push(`export interface ${interfaceName} {`); for (const method of methods) { const name = handlerMethodName(method.rpcMethod); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); const pType = hasParams ? paramsTypeName(method) : ""; const rType = tsResultType(method); pushTsRpcMethodJsDoc(lines, " ", method, { summaryFallback: `Handles \`${method.rpcMethod}\`.`, paramsName: hasParams ? "params" : undefined, paramsDescription: rpcParamsDescription(method, getMethodParamsSchema(method)), includeDeprecated: method.deprecated && !groupDeprecated, includeExperimental: method.stability === "experimental" && !groupExperimental, }); if (hasParams) { lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); } else { lines.push(` ${name}(): Promise<${rType}>;`); } } lines.push(`}`); lines.push(""); } // Emit combined ClientSessionApiHandlers type lines.push(`/** All client session API handler groups. */`); lines.push(`export interface ClientSessionApiHandlers {`); for (const [groupName] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; lines.push(` ${groupName}?: ${interfaceName};`); } lines.push(`}`); lines.push(""); // Emit registration function lines.push(`/**`); lines.push(` * Register client session API handlers on a JSON-RPC connection.`); lines.push(` * The server calls these methods to delegate work to the client.`); lines.push(` * Each incoming call includes a \`sessionId\` in the params; the registration`); lines.push(` * function uses \`getHandlers\` to resolve the session's handlers.`); lines.push(` */`); lines.push(`export function registerClientSessionApiHandlers(`); lines.push(` connection: MessageConnection,`); lines.push(` getHandlers: (sessionId: string) => ClientSessionApiHandlers,`); lines.push(`): void {`); for (const [groupName, methods] of groups) { for (const method of methods) { const name = handlerMethodName(method.rpcMethod); const pType = paramsTypeName(method); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); lines.push(` const handler = getHandlers(params.sessionId).${groupName};`); lines.push(` if (!handler) throw new Error(\`No ${groupName} handler registered for session: \${params.sessionId}\`);`); lines.push(` return handler.${name}(params);`); lines.push(` });`); } else { lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); lines.push(` throw new Error("No params provided for ${method.rpcMethod}");`); lines.push(` });`); } } } lines.push(`}`); lines.push(""); return lines; } /** * Generate handler interfaces and a registration function for client *global* * API groups. * * Unlike client-session APIs, these methods carry no implicit `sessionId` * dispatch key. The SDK consumer registers a single process-wide handler set * via `registerClientGlobalApiHandlers`; the runtime dispatcher routes each * incoming call to the registered handler regardless of which (if any) * runtime session triggered it. */ function emitClientGlobalApiRegistration(clientSchema: Record): string[] { const lines: string[] = []; const groups = collectClientGroups(clientSchema); for (const [groupName, methods] of groups) { const interfaceName = toPascalCase(groupName) + "Handler"; const publicMethods = methods.filter((m) => m.visibility !== "internal"); // Skip groups that have no public methods — they are handled internally by the SDK. if (publicMethods.length === 0) continue; const groupDeprecated = isNodeFullyDeprecated(clientSchema[groupName] as Record); const groupExperimental = isNodeFullyExperimental(clientSchema[groupName] as Record); if (groupDeprecated) { lines.push(`/** @deprecated Handler for \`${groupName}\` client global API methods. */`); } else if (groupExperimental) { lines.push(`/** Handler for \`${groupName}\` client global API methods. */`); lines.push(TS_EXPERIMENTAL_JSDOC); } else { lines.push(`/** Handler for \`${groupName}\` client global API methods. */`); } lines.push(`export interface ${interfaceName} {`); for (const method of publicMethods) { const name = handlerMethodName(method.rpcMethod); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); const pType = hasParams ? paramsTypeName(method) : ""; const rType = tsResultType(method); pushTsRpcMethodJsDoc(lines, " ", method, { summaryFallback: `Handles \`${method.rpcMethod}\`.`, paramsName: hasParams ? "params" : undefined, paramsDescription: rpcParamsDescription(method, getMethodParamsSchema(method)), includeDeprecated: method.deprecated && !groupDeprecated, includeExperimental: method.stability === "experimental" && !groupExperimental, }); if (hasParams) { lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); } else { lines.push(` ${name}(): Promise<${rType}>;`); } } lines.push(`}`); lines.push(""); } lines.push(`/** All client global API handler groups. */`); lines.push(`export interface ClientGlobalApiHandlers {`); for (const [groupName, methods] of groups) { const publicMethods = methods.filter((m) => m.visibility !== "internal"); if (publicMethods.length === 0) continue; const interfaceName = toPascalCase(groupName) + "Handler"; lines.push(` ${groupName}?: ${interfaceName};`); } lines.push(`}`); lines.push(""); lines.push(`/**`); lines.push(` * Register client global API handlers on a JSON-RPC connection.`); lines.push(` * The server calls these methods to delegate work to the client.`); lines.push(` * Unlike session-scoped client APIs, these methods carry no implicit`); lines.push(` * \`sessionId\` dispatch key — a single set of handlers serves the entire`); lines.push(` * connection.`); lines.push(` */`); lines.push(`export function registerClientGlobalApiHandlers(`); lines.push(` connection: MessageConnection,`); lines.push(` handlers: ClientGlobalApiHandlers,`); lines.push(`): void {`); for (const [groupName, methods] of groups) { // Only wire up public methods; internal methods are handled directly by the SDK. const publicMethods = methods.filter((m) => m.visibility !== "internal"); if (publicMethods.length === 0) continue; for (const method of publicMethods) { const name = handlerMethodName(method.rpcMethod); const pType = paramsTypeName(method); const hasParams = hasSchemaPayload(getMethodParamsSchema(method)); if (method.notification) { // Notification methods carry no response; the server dispatches // them via `sendNotification`, which only fires `onNotification` // handlers (an `onRequest` handler would never be invoked). if (hasParams) { lines.push(` connection.onNotification("${method.rpcMethod}", async (params: ${pType}) => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) return;`); lines.push(` await handler.${name}(params);`); lines.push(` });`); } else { lines.push(` connection.onNotification("${method.rpcMethod}", async () => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) return;`); lines.push(` await handler.${name}();`); lines.push(` });`); } } else if (hasParams) { lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) throw new Error("No ${groupName} client-global handler registered");`); lines.push(` return handler.${name}(params);`); lines.push(` });`); } else { lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); lines.push(` const handler = handlers.${groupName};`); lines.push(` if (!handler) throw new Error("No ${groupName} client-global handler registered");`); lines.push(` return handler.${name}();`); lines.push(` });`); } } } lines.push(`}`); lines.push(""); return lines; } // ── Main ──────────────────────────────────────────────────────────────────── async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { await generateSessionEvents(sessionSchemaPath); try { const resolvedSessionPath = sessionSchemaPath ?? (await getSessionEventsSchemaPath()); const sessionSchema = propagateInternalVisibility(postProcessSchema((await loadSchemaJson(resolvedSessionPath)) as JSONSchema7)); await generateRpc(apiSchemaPath, sessionSchema); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { console.log("TypeScript: skipping RPC (api.schema.json not found)"); } else { throw err; } } } const __filename = fileURLToPath(import.meta.url); if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { const sessionArg = process.argv[2] || undefined; const apiArg = process.argv[3] || undefined; generate(sessionArg, apiArg).catch((err) => { console.error("TypeScript generation failed:", err); process.exit(1); }); }