Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

revert xrpc-server and add backwards compat to lex-gen

+3702 -978
+9 -1
deno.lock
··· 33 33 "jsr:@std/text@~1.0.7": "1.0.16", 34 34 "jsr:@ts-morph/common@0.27": "0.27.0", 35 35 "jsr:@ts-morph/ts-morph@26": "26.0.0", 36 + "jsr:@zod/zod@^4.1.11": "4.3.6", 36 37 "jsr:@zod/zod@^4.1.13": "4.3.6", 37 38 "npm:@atproto/crypto@*": "0.1.0", 38 39 "npm:@did-plc/lib@^0.0.4": "0.0.4", ··· 45 46 "npm:key-encoder@^2.0.3": "2.0.3", 46 47 "npm:multiformats@^13.4.1": "13.4.1", 47 48 "npm:p-queue@^8.1.1": "8.1.1", 49 + "npm:prettier@^3.6.2": "3.8.1", 48 50 "npm:rate-limiter-flexible@9": "9.0.0", 49 51 "npm:ws@^8.18.0": "8.18.3" 50 52 }, ··· 883 885 "xtend" 884 886 ] 885 887 }, 888 + "prettier@3.8.1": { 889 + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", 890 + "bin": true 891 + }, 886 892 "process-warning@3.0.0": { 887 893 "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" 888 894 }, ··· 1147 1153 "jsr:@std/fs@^1.0.19", 1148 1154 "jsr:@std/jsonc@^1.0.1", 1149 1155 "jsr:@std/path@^1.1.2", 1150 - "jsr:@ts-morph/ts-morph@26" 1156 + "jsr:@ts-morph/ts-morph@26", 1157 + "jsr:@zod/zod@^4.1.11", 1158 + "npm:prettier@^3.6.2" 1151 1159 ] 1152 1160 }, 1153 1161 "lexicon": {
+1 -1
lex-gen/cmd/build.ts
··· 13 13 .option( 14 14 "-o, --out <out>", 15 15 "output directory for generated TS files", 16 - { required: true, default: "./src/lexicons" }, 16 + { required: true, default: "./lex" }, 17 17 ) 18 18 .option("--clear", "clear output directory before generating files", { 19 19 default: false,
+81
lex-gen/cmd/gen-api.ts
··· 1 + import { Command } from "@cliffy/command"; 2 + import { 3 + applyFileDiff, 4 + genFileDiff, 5 + printFileDiff, 6 + readAllLexicons, 7 + shouldPullLexicons, 8 + } from "../util.ts"; 9 + import { genClientApi } from "../codegen/client.ts"; 10 + import { formatGeneratedFiles } from "../codegen/util.ts"; 11 + import { loadLexiconConfig } from "../config.ts"; 12 + import { cleanupPullDirectory, pullLexicons } from "../pull.ts"; 13 + import process from "node:process"; 14 + 15 + const command = new Command() 16 + .description("Generate a TS client API") 17 + .option("--js", "use .js extension for imports instead of .ts") 18 + .option("-o, --outdir <outdir>", "dir path to write to") 19 + .option("-i, --input <input...>", "paths of lexicon files to include") 20 + .option("--config <config>", "path to config file") 21 + .action( 22 + async ({ outdir, input, js, config: configPath }) => { 23 + const config = await loadLexiconConfig(configPath); 24 + const finalOutdir = outdir ?? config?.outdir; 25 + const finalInput = input ?? config?.files; 26 + 27 + if (!finalOutdir) { 28 + console.error("outdir is required (provide via -o/--outdir or config)"); 29 + if (typeof Deno !== "undefined") { 30 + Deno.exit(1); 31 + } else { 32 + process.exit(1); 33 + } 34 + } 35 + 36 + if (!finalInput || finalInput.length === 0) { 37 + console.error( 38 + "input is required (provide via -i/--input or config.files)", 39 + ); 40 + if (typeof Deno !== "undefined") { 41 + Deno.exit(1); 42 + } else { 43 + process.exit(1); 44 + } 45 + } 46 + 47 + const filesProvidedViaCli = input !== undefined; 48 + const needsPull = shouldPullLexicons( 49 + config, 50 + filesProvidedViaCli, 51 + finalInput, 52 + ); 53 + if (needsPull && config?.pull) { 54 + await pullLexicons(config.pull); 55 + } 56 + 57 + const useJs = js ?? false; 58 + const importSuffix = config?.modules?.importSuffix; 59 + const mappings = config?.mappings; 60 + const lexicons = readAllLexicons(finalInput); 61 + const api = await genClientApi(lexicons, { 62 + useJsExtension: useJs, 63 + importSuffix: importSuffix, 64 + mappings: mappings, 65 + }); 66 + const diff = genFileDiff(finalOutdir, api); 67 + console.log("This will write the following files:"); 68 + printFileDiff(diff); 69 + applyFileDiff(diff); 70 + if (typeof Deno !== "undefined") { 71 + await formatGeneratedFiles(finalOutdir); 72 + } 73 + console.log("API generated."); 74 + 75 + if (needsPull && config?.pull) { 76 + cleanupPullDirectory(config.pull); 77 + } 78 + }, 79 + ); 80 + 81 + export default command;
+72
lex-gen/cmd/gen-md.ts
··· 1 + import { Command } from "@cliffy/command"; 2 + import { readAllLexicons, shouldPullLexicons } from "../util.ts"; 3 + import * as mdGen from "../mdgen/index.ts"; 4 + import { loadLexiconConfig } from "../config.ts"; 5 + import { cleanupPullDirectory, pullLexicons } from "../pull.ts"; 6 + import process from "node:process"; 7 + 8 + const isDeno = typeof Deno !== "undefined"; 9 + 10 + const command = new Command() 11 + .description("Generate markdown documentation") 12 + .option("-o, --output <outfile>", "Output file path") 13 + .option("-i, --input <infile>", "Input file path") 14 + .option("--config <config>", "path to config file") 15 + .action( 16 + async ({ output, input, config: configPath }) => { 17 + const config = await loadLexiconConfig(configPath); 18 + const finalOutput = output ?? 19 + (config?.outdir ? `${config.outdir}/docs.md` : undefined); 20 + const finalInput = input ?? config?.files?.[0]; 21 + 22 + if (!finalOutput) { 23 + console.error("output is required (provide via -o/--output or config)"); 24 + if (isDeno) { 25 + Deno.exit(1); 26 + } else { 27 + process.exit(1); 28 + } 29 + } 30 + 31 + if (!finalInput) { 32 + console.error( 33 + "input is required (provide via -i/--input or config.files)", 34 + ); 35 + if (isDeno) { 36 + Deno.exit(1); 37 + } else { 38 + process.exit(1); 39 + } 40 + } 41 + 42 + if (!finalOutput.endsWith(".md")) { 43 + console.error( 44 + "Must supply the path to a .md file", 45 + ); 46 + if (isDeno) { 47 + Deno.exit(1); 48 + } else { 49 + process.exit(1); 50 + } 51 + } 52 + 53 + const filesProvidedViaCli = input !== undefined; 54 + const needsPull = shouldPullLexicons( 55 + config, 56 + filesProvidedViaCli, 57 + [finalInput], 58 + ); 59 + if (needsPull && config?.pull) { 60 + await pullLexicons(config.pull); 61 + } 62 + 63 + const lexicons = readAllLexicons(finalInput); 64 + await mdGen.process(finalOutput, lexicons); 65 + 66 + if (needsPull && config?.pull) { 67 + cleanupPullDirectory(config.pull); 68 + } 69 + }, 70 + ); 71 + 72 + export default command;
+85
lex-gen/cmd/gen-server.ts
··· 1 + import { Command } from "@cliffy/command"; 2 + import { 3 + applyFileDiff, 4 + genFileDiff, 5 + printFileDiff, 6 + readAllLexicons, 7 + shouldPullLexicons, 8 + } from "../util.ts"; 9 + import { formatGeneratedFiles } from "../codegen/util.ts"; 10 + import { genServerApi } from "../codegen/server.ts"; 11 + import { loadLexiconConfig } from "../config.ts"; 12 + import { cleanupPullDirectory, pullLexicons } from "../pull.ts"; 13 + import process from "node:process"; 14 + 15 + const isDeno = typeof Deno !== "undefined"; 16 + 17 + const command = new Command() 18 + .description("Generate a TS server API") 19 + .option("--js", "use .js extension for imports instead of .ts") 20 + .option("-o, --outdir <outdir>", "dir path to write to") 21 + .option("-i, --input <input...>", "paths of lexicon files to include") 22 + .option("--config <config>", "path to config file") 23 + .action( 24 + async ({ outdir, input, js, config: configPath }) => { 25 + const config = await loadLexiconConfig(configPath); 26 + const finalOutdir = outdir ?? config?.outdir; 27 + const finalInput = input ?? config?.files; 28 + 29 + if (!finalOutdir) { 30 + console.error("outdir is required (provide via -o/--outdir or config)"); 31 + if (isDeno) { 32 + Deno.exit(1); 33 + } else { 34 + process.exit(1); 35 + } 36 + } 37 + 38 + if (!finalInput || finalInput.length === 0) { 39 + console.error( 40 + "input is required (provide via -i/--input or config.files)", 41 + ); 42 + if (isDeno) { 43 + Deno.exit(1); 44 + } else { 45 + process.exit(1); 46 + } 47 + } 48 + 49 + const filesProvidedViaCli = input !== undefined; 50 + const needsPull = shouldPullLexicons( 51 + config, 52 + filesProvidedViaCli, 53 + finalInput, 54 + ); 55 + if (needsPull && config?.pull) { 56 + await pullLexicons(config.pull); 57 + } 58 + 59 + const useJs = js ?? false; 60 + const importSuffix = config?.modules?.importSuffix; 61 + const mappings = config?.mappings; 62 + console.log("Generating API..."); 63 + const lexicons = readAllLexicons(finalInput); 64 + const api = await genServerApi(lexicons, { 65 + useJsExtension: useJs, 66 + importSuffix: importSuffix, 67 + mappings: mappings, 68 + }); 69 + console.log("API generated."); 70 + const diff = genFileDiff(finalOutdir, api); 71 + console.log("This will write the following files:"); 72 + printFileDiff(diff); 73 + applyFileDiff(diff); 74 + if (typeof Deno !== "undefined") { 75 + await formatGeneratedFiles(finalOutdir); 76 + } 77 + console.log("API generated."); 78 + 79 + if (needsPull && config?.pull) { 80 + cleanupPullDirectory(config.pull); 81 + } 82 + }, 83 + ); 84 + 85 + export default command;
+49
lex-gen/cmd/gen-ts-obj.ts
··· 1 + import { Command } from "@cliffy/command"; 2 + import { genTsObj, readAllLexicons, shouldPullLexicons } from "../util.ts"; 3 + import { loadLexiconConfig } from "../config.ts"; 4 + import { cleanupPullDirectory, pullLexicons } from "../pull.ts"; 5 + import process from "node:process"; 6 + 7 + const isDeno = typeof Deno !== "undefined"; 8 + 9 + const command = new Command() 10 + .description("Generate a TS file that exports an array of lexicons") 11 + .option("-i, --input <lexicons>", "paths of the lexicon files to include") 12 + .option("--config <config>", "path to config file") 13 + .action(async ({ input, config: configPath }) => { 14 + const config = await loadLexiconConfig(configPath); 15 + const finalInput = input ?? config?.files; 16 + 17 + if (!finalInput || finalInput.length === 0) { 18 + console.error( 19 + "input is required (provide via -i/--input or config.files)", 20 + ); 21 + if (isDeno) { 22 + Deno.exit(1); 23 + } else { 24 + process.exit(1); 25 + } 26 + } 27 + 28 + const filesProvidedViaCli = input !== undefined; 29 + const finalInputArray = Array.isArray(finalInput) 30 + ? finalInput 31 + : [finalInput]; 32 + const needsPull = shouldPullLexicons( 33 + config, 34 + filesProvidedViaCli, 35 + finalInputArray, 36 + ); 37 + if (needsPull && config?.pull) { 38 + await pullLexicons(config.pull); 39 + } 40 + 41 + const lexicons = readAllLexicons(finalInput); 42 + console.log(genTsObj(lexicons)); 43 + 44 + if (needsPull && config?.pull) { 45 + cleanupPullDirectory(config.pull); 46 + } 47 + }); 48 + 49 + export default command;
+5 -1
lex-gen/cmd/index.ts
··· 1 1 import build from "./build.ts"; 2 + import genMd from "./gen-md.ts"; 3 + import genApi from "./gen-api.ts"; 4 + import genServer from "./gen-server.ts"; 5 + import genTsObj from "./gen-ts-obj.ts"; 2 6 3 - export { build }; 7 + export { build, genApi, genMd, genServer, genTsObj };
+652
lex-gen/codegen/client.ts
··· 1 + import { 2 + IndentationText, 3 + Project, 4 + type SourceFile, 5 + VariableDeclarationKind, 6 + } from "ts-morph"; 7 + import { type LexiconDoc, Lexicons, type LexRecord } from "@atp/lexicon"; 8 + import { NSID } from "@atp/syntax"; 9 + import type { GeneratedAPI } from "../types.ts"; 10 + import { gen, lexiconsTs, utilTs } from "./common.ts"; 11 + import { 12 + collectExternalImports, 13 + genCommonImports, 14 + genImports, 15 + genRecord, 16 + genUserType, 17 + genXrpcInput, 18 + genXrpcOutput, 19 + genXrpcParams, 20 + resolveExternalImport, 21 + } from "./lex-gen.ts"; 22 + import { 23 + type CodeGenOptions, 24 + type DefTreeNode, 25 + lexiconsToDefTree, 26 + schemasToNsidTokens, 27 + toCamelCase, 28 + toScreamingSnakeCase, 29 + toTitleCase, 30 + } from "./util.ts"; 31 + 32 + const ATP_METHODS = { 33 + list: "com.atproto.repo.listRecords", 34 + get: "com.atproto.repo.getRecord", 35 + create: "com.atproto.repo.createRecord", 36 + put: "com.atproto.repo.putRecord", 37 + delete: "com.atproto.repo.deleteRecord", 38 + }; 39 + 40 + export async function genClientApi( 41 + lexiconDocs: LexiconDoc[], 42 + options?: CodeGenOptions, 43 + ): Promise<GeneratedAPI> { 44 + const project = new Project({ 45 + useInMemoryFileSystem: true, 46 + manipulationSettings: { indentationText: IndentationText.TwoSpaces }, 47 + }); 48 + const api: GeneratedAPI = { files: [] }; 49 + const lexicons = new Lexicons(lexiconDocs); 50 + const nsidTree = lexiconsToDefTree(lexiconDocs); 51 + const nsidTokens = schemasToNsidTokens(lexiconDocs); 52 + for (const lexiconDoc of lexiconDocs) { 53 + api.files.push(await lexiconTs(project, lexicons, lexiconDoc, options)); 54 + } 55 + api.files.push(await utilTs(project)); 56 + api.files.push(await lexiconsTs(project, lexiconDocs, options)); 57 + api.files.push( 58 + await indexTs(project, lexiconDocs, nsidTree, nsidTokens, options), 59 + ); 60 + return api; 61 + } 62 + 63 + const indexTs = ( 64 + project: Project, 65 + lexiconDocs: LexiconDoc[], 66 + nsidTree: DefTreeNode[], 67 + nsidTokens: Record<string, string[]>, 68 + options?: CodeGenOptions, 69 + ) => 70 + gen(project, "/index.ts", (file) => { 71 + const importExtension = options?.importSuffix ?? 72 + (options?.useJsExtension ? ".js" : ".ts"); 73 + //= import { XrpcClient, type FetchHandler, type FetchHandlerOptions } from '@atp/xrpc' 74 + file.addImportDeclaration({ 75 + moduleSpecifier: "@atp/xrpc", 76 + namedImports: [ 77 + { name: "XrpcClient" }, 78 + { name: "FetchHandler", isTypeOnly: true }, 79 + { name: "FetchHandlerOptions", isTypeOnly: true }, 80 + ], 81 + }); 82 + //= import {schemas} from './lexicons.ts' 83 + file.addImportDeclaration({ 84 + moduleSpecifier: `./lexicons${importExtension}`, 85 + namedImports: [{ name: "schemas" }], 86 + }); 87 + 88 + //= import { type OmitKey, type Un$Typed } from './util.ts' 89 + file.addImportDeclaration({ 90 + moduleSpecifier: `./util${importExtension}`, 91 + isTypeOnly: true, 92 + namedImports: [ 93 + { name: "OmitKey" }, 94 + { name: "Un$Typed" }, 95 + ], 96 + }); 97 + 98 + // collect and import external lexicon references 99 + const externalImports = collectExternalImports(lexiconDocs, options); 100 + const mappings = options?.mappings; 101 + for (const [nsid, types] of externalImports) { 102 + const mapping = resolveExternalImport(nsid, mappings); 103 + if (mapping) { 104 + if (typeof mapping.imports === "string") { 105 + file.addImportDeclaration({ 106 + isTypeOnly: true, 107 + moduleSpecifier: mapping.imports, 108 + namedImports: [{ name: toTitleCase(nsid), isTypeOnly: true }], 109 + }); 110 + } else { 111 + const result = mapping.imports(nsid); 112 + if (result.type === "namespace") { 113 + file.addImportDeclaration({ 114 + isTypeOnly: true, 115 + moduleSpecifier: result.from, 116 + namespaceImport: toTitleCase(nsid), 117 + }); 118 + } else { 119 + const namedImports = Array.from(types).map((typeName) => ({ 120 + name: toTitleCase(typeName), 121 + isTypeOnly: true, 122 + })); 123 + file.addImportDeclaration({ 124 + isTypeOnly: true, 125 + moduleSpecifier: result.from, 126 + namedImports, 127 + }); 128 + } 129 + } 130 + } 131 + } 132 + 133 + // generate type imports and re-exports 134 + for (const lexicon of lexiconDocs) { 135 + const moduleSpecifier = `./types/${ 136 + lexicon.id.split(".").join("/") 137 + }${importExtension}`; 138 + 139 + const defs = Object.values(lexicon.defs); 140 + const hasRecord = defs.some((d) => d.type === "record"); 141 + const hasQueryOrProc = defs.some( 142 + (d) => d.type === "query" || d.type === "procedure", 143 + ); 144 + const needsValue = defs.some( 145 + (d) => 146 + (d.type === "query" || d.type === "procedure") && d.errors?.length, 147 + ); 148 + 149 + if (hasRecord || hasQueryOrProc) { 150 + file.addImportDeclaration({ 151 + moduleSpecifier, 152 + isTypeOnly: !needsValue, 153 + namespaceImport: toTitleCase(lexicon.id), 154 + }); 155 + } 156 + 157 + file 158 + .addExportDeclaration({ moduleSpecifier }) 159 + .setNamespaceExport(toTitleCase(lexicon.id)); 160 + } 161 + 162 + // generate token enums 163 + for (const nsidAuthority in nsidTokens) { 164 + // export const {THE_AUTHORITY} = { 165 + // {Name}: "{authority.the.name}" 166 + // } 167 + file.addVariableStatement({ 168 + isExported: true, 169 + declarationKind: VariableDeclarationKind.Const, 170 + declarations: [ 171 + { 172 + name: toScreamingSnakeCase(nsidAuthority), 173 + initializer: [ 174 + "{", 175 + ...nsidTokens[nsidAuthority].map( 176 + (nsidName) => 177 + `${toTitleCase(nsidName)}: "${nsidAuthority}.${nsidName}",`, 178 + ), 179 + "}", 180 + ].join("\n"), 181 + }, 182 + ], 183 + }); 184 + } 185 + 186 + //= export class AtpBaseClient {...} 187 + const clientCls = file.addClass({ 188 + name: "AtpBaseClient", 189 + isExported: true, 190 + extends: "XrpcClient", 191 + }); 192 + 193 + for (const ns of nsidTree) { 194 + //= ns: NS 195 + clientCls.addProperty({ 196 + name: ns.propName, 197 + type: ns.className, 198 + }); 199 + } 200 + 201 + //= constructor (options: FetchHandler | FetchHandlerOptions) { 202 + //= super(options, schemas) 203 + //= {namespace declarations} 204 + //= } 205 + clientCls.addConstructor({ 206 + parameters: [ 207 + { name: "options", type: "FetchHandler | FetchHandlerOptions" }, 208 + ], 209 + statements: [ 210 + "super(options, schemas)", 211 + ...nsidTree.map( 212 + (ns) => `this.${ns.propName} = new ${ns.className}(this)`, 213 + ), 214 + ], 215 + }); 216 + 217 + //= /** @deprecated use `this` instead */ 218 + //= get xrpc(): XrpcClient { 219 + //= return this 220 + //= } 221 + clientCls 222 + .addGetAccessor({ 223 + name: "xrpc", 224 + returnType: "XrpcClient", 225 + statements: ["return this"], 226 + }) 227 + .addJsDoc("@deprecated use `this` instead"); 228 + 229 + // generate classes for the schemas 230 + for (const ns of nsidTree) { 231 + genNamespaceCls(file, ns); 232 + } 233 + }); 234 + 235 + function genNamespaceCls(file: SourceFile, ns: DefTreeNode) { 236 + //= export class {ns}NS {...} 237 + const cls = file.addClass({ 238 + name: ns.className, 239 + isExported: true, 240 + }); 241 + //= _client: XrpcClient 242 + cls.addProperty({ 243 + name: "_client", 244 + type: "XrpcClient", 245 + }); 246 + 247 + for (const userType of ns.userTypes) { 248 + if (userType.def.type !== "record") { 249 + continue; 250 + } 251 + //= type: TypeRecord 252 + const name = NSID.parse(userType.nsid).name || ""; 253 + cls.addProperty({ 254 + name: toCamelCase(name), 255 + type: `${toTitleCase(userType.nsid)}Record`, 256 + }); 257 + } 258 + 259 + for (const child of ns.children) { 260 + //= child: ChildNS 261 + cls.addProperty({ 262 + name: child.propName, 263 + type: child.className, 264 + }); 265 + 266 + // recurse 267 + genNamespaceCls(file, child); 268 + } 269 + 270 + //= constructor(public client: XrpcClient) { 271 + //= this._client = client 272 + //= {child namespace prop declarations} 273 + //= {record prop declarations} 274 + //= } 275 + cls.addConstructor({ 276 + parameters: [ 277 + { 278 + name: "client", 279 + type: "XrpcClient", 280 + }, 281 + ], 282 + statements: [ 283 + `this._client = client`, 284 + ...ns.children.map( 285 + (ns) => `this.${ns.propName} = new ${ns.className}(client)`, 286 + ), 287 + ...ns.userTypes 288 + .filter((ut) => ut.def.type === "record") 289 + .map((ut) => { 290 + const name = NSID.parse(ut.nsid).name || ""; 291 + return `this.${toCamelCase(name)} = new ${ 292 + toTitleCase( 293 + ut.nsid, 294 + ) 295 + }Record(client)`; 296 + }), 297 + ], 298 + }); 299 + 300 + // methods 301 + for (const userType of ns.userTypes) { 302 + if (userType.def.type !== "query" && userType.def.type !== "procedure") { 303 + continue; 304 + } 305 + const isGetReq = userType.def.type === "query"; 306 + const moduleName = toTitleCase(userType.nsid); 307 + const name = toCamelCase(NSID.parse(userType.nsid).name || ""); 308 + const method = cls.addMethod({ 309 + name, 310 + returnType: `Promise<${moduleName}.Response>`, 311 + }); 312 + if (isGetReq) { 313 + method.addParameter({ 314 + name: "params?", 315 + type: `${moduleName}.QueryParams`, 316 + }); 317 + } else if (userType.def.type === "procedure") { 318 + method.addParameter({ 319 + name: "data?", 320 + type: `${moduleName}.InputSchema`, 321 + }); 322 + } 323 + method.addParameter({ 324 + name: "opts?", 325 + type: `${moduleName}.CallOptions`, 326 + }); 327 + method.setBodyText( 328 + [ 329 + `return this._client`, 330 + isGetReq 331 + ? `.call('${userType.nsid}', params, undefined, opts)` 332 + : `.call('${userType.nsid}', opts?.qp, data, opts)`, 333 + userType.def.errors?.length 334 + // Only add a catch block if there are custom errors 335 + ? ` .catch((e) => { throw ${moduleName}.toKnownErr(e) })` 336 + : "", 337 + ].join("\n"), 338 + ); 339 + } 340 + 341 + // record api classes 342 + for (const userType of ns.userTypes) { 343 + if (userType.def.type !== "record") { 344 + continue; 345 + } 346 + genRecordCls(file, userType.nsid, userType.def); 347 + } 348 + } 349 + 350 + function genRecordCls(file: SourceFile, nsid: string, lexRecord: LexRecord) { 351 + //= export class {type}Record {...} 352 + const cls = file.addClass({ 353 + name: `${toTitleCase(nsid)}Record`, 354 + isExported: true, 355 + }); 356 + //= _client: XrpcClient 357 + cls.addProperty({ 358 + name: "_client", 359 + type: "XrpcClient", 360 + }); 361 + 362 + //= constructor(client: XrpcClient) { 363 + //= this._client = client 364 + //= } 365 + const cons = cls.addConstructor(); 366 + cons.addParameter({ 367 + name: "client", 368 + type: "XrpcClient", 369 + }); 370 + cons.setBodyText(`this._client = client`); 371 + 372 + // methods 373 + const typeModule = toTitleCase(nsid); 374 + { 375 + //= list() 376 + const method = cls.addMethod({ 377 + isAsync: true, 378 + name: "list", 379 + returnType: 380 + `Promise<{cursor?: string, records: ({uri: string, value: ${typeModule}.Record})[]}>`, 381 + }); 382 + method.addParameter({ 383 + name: "params", 384 + type: `OmitKey<${ 385 + toTitleCase(ATP_METHODS.list) 386 + }.QueryParams, "collection">`, 387 + }); 388 + method.setBodyText( 389 + [ 390 + `const res = await this._client.call('${ATP_METHODS.list}', { collection: '${nsid}', ...params })`, 391 + `return res.data`, 392 + ].join("\n"), 393 + ); 394 + } 395 + { 396 + //= get() 397 + const method = cls.addMethod({ 398 + isAsync: true, 399 + name: "get", 400 + returnType: 401 + `Promise<{uri: string, cid: string, value: ${typeModule}.Record}>`, 402 + }); 403 + method.addParameter({ 404 + name: "params", 405 + type: `OmitKey<${ 406 + toTitleCase(ATP_METHODS.get) 407 + }.QueryParams, "collection">`, 408 + }); 409 + method.setBodyText( 410 + [ 411 + `const res = await this._client.call('${ATP_METHODS.get}', { collection: '${nsid}', ...params })`, 412 + `return res.data`, 413 + ].join("\n"), 414 + ); 415 + } 416 + { 417 + //= create() 418 + const method = cls.addMethod({ 419 + isAsync: true, 420 + name: "create", 421 + returnType: "Promise<{uri: string, cid: string}>", 422 + }); 423 + method.addParameter({ 424 + name: "params", 425 + type: `OmitKey<${ 426 + toTitleCase( 427 + ATP_METHODS.create, 428 + ) 429 + }.InputSchema, "collection" | "record">`, 430 + }); 431 + method.addParameter({ 432 + name: "record", 433 + type: `Un$Typed<${typeModule}.Record>`, 434 + }); 435 + method.addParameter({ 436 + name: "headers?", 437 + type: `Record<string, string>`, 438 + }); 439 + const maybeRkeyPart = lexRecord.key?.startsWith("literal:") 440 + ? `rkey: '${lexRecord.key.replace("literal:", "")}', ` 441 + : ""; 442 + method.setBodyText( 443 + [ 444 + `const collection = '${nsid}'`, 445 + `const res = await this._client.call('${ATP_METHODS.create}', undefined, { collection, ${maybeRkeyPart}...params, record: { ...record, $type: collection} }, {encoding: 'application/json', headers })`, 446 + `return res.data`, 447 + ].join("\n"), 448 + ); 449 + } 450 + // { 451 + // //= put() 452 + // const method = cls.addMethod({ 453 + // isAsync: true, 454 + // name: 'put', 455 + // returnType: 'Promise<{uri: string, cid: string}>', 456 + // }) 457 + // method.addParameter({ 458 + // name: 'params', 459 + // type: `OmitKey<${toTitleCase(ATP_METHODS.put)}.InputSchema, "collection" | "record">`, 460 + // }) 461 + // method.addParameter({ 462 + // name: 'record', 463 + // type: `${typeModule}.Record`, 464 + // }) 465 + // method.addParameter({ 466 + // name: 'headers?', 467 + // type: `Record<string, string>`, 468 + // }) 469 + // method.setBodyText( 470 + // [ 471 + // `record.$type = '${userType.nsid}'`, 472 + // `const res = await this._client.call('${ATP_METHODS.put}', undefined, { collection: '${userType.nsid}', record, ...params }, {encoding: 'application/json', headers})`, 473 + // `return res.data`, 474 + // ].join('\n'), 475 + // ) 476 + // } 477 + { 478 + //= delete() 479 + const method = cls.addMethod({ 480 + isAsync: true, 481 + name: "delete", 482 + returnType: "Promise<void>", 483 + }); 484 + method.addParameter({ 485 + name: "params", 486 + type: `OmitKey<${ 487 + toTitleCase( 488 + ATP_METHODS.delete, 489 + ) 490 + }.InputSchema, "collection">`, 491 + }); 492 + method.addParameter({ 493 + name: "headers?", 494 + type: `Record<string, string>`, 495 + }); 496 + 497 + method.setBodyText( 498 + [ 499 + `await this._client.call('${ATP_METHODS.delete}', undefined, { collection: '${nsid}', ...params }, { headers })`, 500 + ].join("\n"), 501 + ); 502 + } 503 + } 504 + 505 + const lexiconTs = ( 506 + project: Project, 507 + lexicons: Lexicons, 508 + lexiconDoc: LexiconDoc, 509 + options?: CodeGenOptions, 510 + ) => 511 + gen( 512 + project, 513 + `/types/${lexiconDoc.id.split(".").join("/")}.ts`, 514 + (file) => { 515 + // Filter out subscriptions as they are not currently generated for client 516 + const filteredDefs = Object.fromEntries( 517 + Object.entries(lexiconDoc.defs).filter(([_, def]) => 518 + def.type !== "subscription" 519 + ), 520 + ); 521 + const filteredDoc = { ...lexiconDoc, defs: filteredDefs }; 522 + 523 + const main = filteredDoc.defs.main; 524 + if ( 525 + main?.type === "query" || 526 + main?.type === "procedure" 527 + ) { 528 + const needsXrpcError = (main.type === "query" || 529 + main.type === "procedure") && main.errors?.length; 530 + 531 + //= import {HeadersMap, XRPCError} from '@atp/xrpc' 532 + file.addImportDeclaration({ 533 + moduleSpecifier: "@atp/xrpc", 534 + isTypeOnly: !needsXrpcError, 535 + namedImports: needsXrpcError 536 + ? [{ name: "HeadersMap", isTypeOnly: true }, { name: "XRPCError" }] 537 + : [{ name: "HeadersMap" }], 538 + }); 539 + } 540 + 541 + genCommonImports(file, lexiconDoc.id, filteredDoc); 542 + 543 + const imports: Map<string, Set<string>> = new Map(); 544 + for (const defId in filteredDoc.defs) { 545 + const def = filteredDoc.defs[defId]; 546 + const lexUri = `${lexiconDoc.id}#${defId}`; 547 + if (defId === "main") { 548 + if (def.type === "query" || def.type === "procedure") { 549 + genXrpcParams(file, lexicons, lexUri, false); 550 + genXrpcInput(file, imports, lexicons, lexUri, false, options); 551 + genXrpcOutput(file, imports, lexicons, lexUri, false, options); 552 + genClientXrpcCommon(file, lexicons, lexUri); 553 + } else if (def.type === "record") { 554 + genRecord(file, imports, lexicons, lexUri, options); 555 + } else { 556 + genUserType(file, imports, lexicons, lexUri, options); 557 + } 558 + } else { 559 + genUserType(file, imports, lexicons, lexUri, options); 560 + } 561 + } 562 + genImports(file, imports, lexiconDoc.id, options); 563 + return Promise.resolve(); 564 + }, 565 + ); 566 + 567 + function genClientXrpcCommon( 568 + file: SourceFile, 569 + lexicons: Lexicons, 570 + lexUri: string, 571 + ) { 572 + const def = lexicons.getDefOrThrow(lexUri, ["query", "procedure"]); 573 + 574 + //= export interface CallOptions {...} 575 + const opts = file.addInterface({ 576 + name: "CallOptions", 577 + isExported: true, 578 + }); 579 + opts.addProperty({ name: "signal?", type: "AbortSignal" }); 580 + opts.addProperty({ name: "headers?", type: "HeadersMap" }); 581 + if (def.type === "procedure") { 582 + opts.addProperty({ name: "qp?", type: "QueryParams" }); 583 + } 584 + if (def.type === "procedure" && def.input) { 585 + let encodingType = "string"; 586 + if (def.input.encoding !== "*/*") { 587 + encodingType = def.input.encoding 588 + .split(",") 589 + .map((v) => `'${v.trim()}'`) 590 + .join(" | "); 591 + } 592 + opts.addProperty({ 593 + name: "encoding?", 594 + type: encodingType, 595 + }); 596 + } 597 + 598 + // export interface Response {...} 599 + const res = file.addInterface({ 600 + name: "Response", 601 + isExported: true, 602 + }); 603 + res.addProperty({ name: "success", type: "boolean" }); 604 + res.addProperty({ name: "headers", type: "HeadersMap" }); 605 + if (def.output?.schema) { 606 + if (def.output.encoding?.includes(",")) { 607 + res.addProperty({ name: "data", type: "OutputSchema | Uint8Array" }); 608 + } else { 609 + res.addProperty({ name: "data", type: "OutputSchema" }); 610 + } 611 + } else if (def.output?.encoding) { 612 + res.addProperty({ name: "data", type: "Uint8Array" }); 613 + } 614 + 615 + // export class {errcode}Error {...} 616 + const customErrors: { name: string; cls: string }[] = []; 617 + for (const error of def.errors || []) { 618 + let name = toTitleCase(error.name); 619 + if (!name.endsWith("Error")) name += "Error"; 620 + const errCls = file.addClass({ 621 + name, 622 + extends: "XRPCError", 623 + isExported: true, 624 + }); 625 + errCls.addConstructor({ 626 + parameters: [{ name: "src", type: "XRPCError" }], 627 + statements: [ 628 + "super(src.status, src.error, src.message, src.headers, { cause: src })", 629 + ], 630 + }); 631 + 632 + customErrors.push({ name: error.name, cls: name }); 633 + } 634 + 635 + // export function toKnownErr(err: any) {...} 636 + file.addFunction({ 637 + name: "toKnownErr", 638 + isExported: true, 639 + parameters: [{ name: "e", type: "unknown" }], 640 + returnType: "unknown", 641 + statements: customErrors.length 642 + ? [ 643 + "if (e instanceof XRPCError) {", 644 + ...customErrors.map( 645 + (err) => `if (e.error === '${err.name}') return new ${err.cls}(e)`, 646 + ), 647 + "}", 648 + "return e", 649 + ] 650 + : ["return e"], 651 + }); 652 + }
+299
lex-gen/codegen/common.ts
··· 1 + import { 2 + type Project, 3 + type SourceFile, 4 + VariableDeclarationKind, 5 + } from "ts-morph"; 6 + import type { LexiconDoc } from "@atp/lexicon"; 7 + import type { GeneratedFile } from "../types.ts"; 8 + import type { CodeGenOptions } from "./util.ts"; 9 + import { format, type Options as PrettierOptions } from "prettier"; 10 + 11 + const PRETTIER_OPTS: PrettierOptions = { 12 + parser: "typescript", 13 + tabWidth: 2, 14 + semi: false, 15 + singleQuote: true, 16 + trailingComma: "all", 17 + }; 18 + 19 + export const utilTs = ( 20 + project: Project, 21 + ) => 22 + gen(project, "/util.ts", (file) => { 23 + file.replaceWithText(` 24 + import type { ValidationResult } from '@atp/lexicon' 25 + 26 + export type OmitKey<T, K extends keyof T> = { 27 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 28 + } 29 + 30 + export type $Typed<V, T extends string = string> = V & { $type: T } 31 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 32 + 33 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 34 + ? Id 35 + : \`\${Id}#\${Hash}\` 36 + 37 + function isObject<V>(v: V): v is V & object { 38 + return v != null && typeof v === 'object' 39 + } 40 + 41 + function is$type<Id extends string, Hash extends string>( 42 + $type: unknown, 43 + id: Id, 44 + hash: Hash, 45 + ): $type is $Type<Id, Hash> { 46 + return hash === 'main' 47 + ? $type === id 48 + : // $type === \`\${id}#\${hash}\` 49 + typeof $type === 'string' && 50 + $type.length === id.length + 1 + hash.length && 51 + $type.charCodeAt(id.length) === 35 /* '#' */ && 52 + $type.startsWith(id) && 53 + $type.endsWith(hash) 54 + } 55 + ${ 56 + /** 57 + * The construct below allows to properly distinguish open unions. Consider 58 + * the following example: 59 + * 60 + * ```ts 61 + * type Foo = { $type?: $Type<'foo', 'main'>; foo: string } 62 + * type Bar = { $type?: $Type<'bar', 'main'>; bar: string } 63 + * type OpenFooBarUnion = $Typed<Foo> | $Typed<Bar> | { $type: string } 64 + * ``` 65 + * 66 + * In the context of lexicons, when there is a open union as shown above, the 67 + * if `$type` if either `foo` or `bar`, then the object IS of type `Foo` or 68 + * `Bar`. 69 + * 70 + * ```ts 71 + * declare const obj1: OpenFooBarUnion 72 + * if (is$typed(obj1, 'foo', 'main')) { 73 + * obj1.$type // $Type<'foo', 'main'> 74 + * obj1.foo // string 75 + * } 76 + * ``` 77 + * 78 + * Similarly, if an object is of type `unknown`, then the `is$typed` function 79 + * should only return assurance about the `$type` property, which is what it 80 + * actually checks: 81 + * 82 + * ```ts 83 + * declare const obj2: unknown 84 + * if (is$typed(obj2, 'foo', 'main')) { 85 + * obj2.$type // $Type<'foo', 'main'> 86 + * // @ts-expect-error 87 + * obj2.foo 88 + * } 89 + * ``` 90 + * 91 + * The construct bellow is what makes these two scenarios possible. 92 + */ 93 + ""} 94 + export type $TypedObject<V, Id extends string, Hash extends string> = V extends { 95 + $type: $Type<Id, Hash> 96 + } 97 + ? V 98 + : V extends { $type?: string } 99 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 100 + ? V & { $type: T } 101 + : never 102 + : V & { $type: $Type<Id, Hash> } 103 + 104 + export function is$typed<V, Id extends string, Hash extends string>( 105 + v: V, 106 + id: Id, 107 + hash: Hash, 108 + ): v is $TypedObject<V, Id, Hash> { 109 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 110 + } 111 + 112 + export function maybe$typed<V, Id extends string, Hash extends string>( 113 + v: V, 114 + id: Id, 115 + hash: Hash, 116 + ): v is V & object & { $type?: $Type<Id, Hash> } { 117 + return ( 118 + isObject(v) && 119 + ('$type' in v 120 + ? v.$type === undefined || is$type(v.$type, id, hash) 121 + : true) 122 + ) 123 + } 124 + 125 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 126 + export type ValidatorParam<V extends Validator> = 127 + V extends Validator<infer R> ? R : never 128 + 129 + /** 130 + * Utility function that allows to convert a "validate*" utility function into a 131 + * type predicate. 132 + */ 133 + export function asPredicate<V extends Validator>(validate: V) { 134 + return function <T>(v: T): v is T & ValidatorParam<V> { 135 + return validate(v).success 136 + } 137 + } 138 + `); 139 + }); 140 + 141 + export const lexiconsTs = ( 142 + project: Project, 143 + lexiconDocs: LexiconDoc[], 144 + options?: CodeGenOptions, 145 + ) => 146 + gen(project, "/lexicons.ts", (file) => { 147 + const importExtension = options?.importSuffix ?? 148 + (options?.useJsExtension ? ".js" : ".ts"); 149 + const nsidToEnum = (nsid: string): string => { 150 + return nsid 151 + .split(".") 152 + .map((word) => word[0].toUpperCase() + word.slice(1)) 153 + .join(""); 154 + }; 155 + 156 + //= import { type LexiconDoc, Lexicons } from '@atp/lexicon' 157 + file 158 + .addImportDeclaration({ 159 + moduleSpecifier: "@atp/lexicon", 160 + }) 161 + .addNamedImports([ 162 + { name: "LexiconDoc", isTypeOnly: true }, 163 + { name: "Lexicons" }, 164 + { name: "ValidationError" }, 165 + { name: "ValidationResult", isTypeOnly: true }, 166 + ]); 167 + 168 + //= import { is$typed, maybe$typed, type $Typed } from "./util${extension}" 169 + file 170 + .addImportDeclaration({ moduleSpecifier: `./util${importExtension}` }) 171 + .addNamedImports([ 172 + { name: "is$typed" }, 173 + { name: "maybe$typed" }, 174 + ]); 175 + 176 + //= export const schemaDict = {...} as const satisfies Record<string, LexiconDoc> 177 + file.addVariableStatement({ 178 + isExported: true, 179 + declarationKind: VariableDeclarationKind.Const, 180 + declarations: [ 181 + { 182 + name: "schemaDict", 183 + initializer: JSON.stringify( 184 + lexiconDocs.reduce( 185 + (acc, cur) => ({ 186 + ...acc, 187 + [nsidToEnum(cur.id)]: cur, 188 + }), 189 + {}, 190 + ), 191 + null, 192 + 2, 193 + ) + " as Record<string, LexiconDoc>", 194 + }, 195 + ], 196 + }); 197 + 198 + //= export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 199 + file.addVariableStatement({ 200 + isExported: true, 201 + declarationKind: VariableDeclarationKind.Const, 202 + declarations: [ 203 + { 204 + name: "schemas", 205 + initializer: "Object.values(schemaDict) satisfies LexiconDoc[]", 206 + }, 207 + ], 208 + }); 209 + 210 + //= export const lexicons: Lexicons = new Lexicons(schemas) 211 + file.addVariableStatement({ 212 + isExported: true, 213 + declarationKind: VariableDeclarationKind.Const, 214 + declarations: [ 215 + { 216 + name: "lexicons", 217 + type: "Lexicons", 218 + initializer: "new Lexicons(schemas)", 219 + }, 220 + ], 221 + }); 222 + 223 + file.addFunction({ 224 + isExported: true, 225 + name: "validate", 226 + overloads: [ 227 + { 228 + typeParameters: ["T extends { $type: string }"], 229 + parameters: [ 230 + { name: "v", type: "unknown" }, 231 + { name: "id", type: "string" }, 232 + { name: "hash", type: "string" }, 233 + { name: "requiredType", type: "true" }, 234 + ], 235 + returnType: "ValidationResult<T>", 236 + }, 237 + { 238 + typeParameters: ["T extends { $type?: string }"], 239 + parameters: [ 240 + { name: "v", type: "unknown" }, 241 + { name: "id", type: "string" }, 242 + { name: "hash", type: "string" }, 243 + { name: "requiredType", type: "false", hasQuestionToken: true }, 244 + ], 245 + returnType: "ValidationResult<T>", 246 + }, 247 + ], 248 + parameters: [ 249 + { name: "v", type: "unknown" }, 250 + { name: "id", type: "string" }, 251 + { name: "hash", type: "string" }, 252 + { name: "requiredType", type: "boolean", hasQuestionToken: true }, 253 + ], 254 + statements: [ 255 + // If $type is present, make sure it is valid before validating the rest of the object 256 + "return (requiredType ? is$typed : maybe$typed)(v, id, hash) ? lexicons.validate(`${id}#${hash}`, v) : { success: false, error: new ValidationError(`Must be an object with \"${hash === 'main' ? id : `${id}#${hash}`}\" $type property`) }", 257 + ], 258 + returnType: "ValidationResult", 259 + }); 260 + 261 + //= export const ids = {...} 262 + file.addVariableStatement({ 263 + isExported: true, 264 + declarationKind: VariableDeclarationKind.Const, 265 + declarations: [ 266 + { 267 + name: "ids", 268 + initializer: `{${ 269 + lexiconDocs 270 + .map( 271 + (lex) => 272 + `\n ${nsidToEnum(lex.id)}: ${JSON.stringify(lex.id)},`, 273 + ) 274 + .join("") 275 + }\n} as const`, 276 + }, 277 + ], 278 + }); 279 + }); 280 + 281 + export async function gen( 282 + project: Project, 283 + path: string, 284 + gen: (file: SourceFile) => void | Promise<void>, 285 + ): Promise<GeneratedFile> { 286 + const file = project.createSourceFile(path); 287 + gen(file); 288 + await file.save(); // Save in the "in memory" file system 289 + let content = `${banner()}${file.getFullText()}`; 290 + if (!(typeof Deno !== "undefined")) { 291 + content = await format(content, PRETTIER_OPTS); 292 + } 293 + 294 + return { path, content }; 295 + } 296 + 297 + function banner() { 298 + return `/**\n * GENERATED CODE - DO NOT MODIFY\n */\n`; 299 + }
+1060
lex-gen/codegen/lex-gen.ts
··· 1 + import { relative as getRelativePath } from "@std/path"; 2 + import { type JSDoc, type SourceFile, VariableDeclarationKind } from "ts-morph"; 3 + import type { 4 + LexArray, 5 + LexBlob, 6 + LexBytes, 7 + LexCidLink, 8 + Lexicons, 9 + LexIpldType, 10 + LexObject, 11 + LexPrimitive, 12 + LexToken, 13 + } from "@atp/lexicon"; 14 + import { 15 + type CodeGenOptions, 16 + toCamelCase, 17 + toScreamingSnakeCase, 18 + toTitleCase, 19 + } from "./util.ts"; 20 + import type { LexiconDoc, LexUserType } from "@atp/lexicon"; 21 + import type { ImportMapping } from "../types.ts"; 22 + 23 + interface Commentable { 24 + addJsDoc: ({ description }: { description: string }) => JSDoc; 25 + } 26 + export function genComment<T extends Commentable>( 27 + commentable: T, 28 + def: { description?: string }, 29 + ): T { 30 + if (def.description) { 31 + commentable.addJsDoc({ description: def.description }); 32 + } 33 + return commentable; 34 + } 35 + 36 + export function genCommonImports( 37 + file: SourceFile, 38 + baseNsid: string, 39 + lexiconDoc: LexiconDoc, 40 + options?: CodeGenOptions, 41 + ) { 42 + const importExtension = options?.importSuffix ?? 43 + (options?.useJsExtension ? ".js" : ".ts"); 44 + const needsBlobRef = Object.values(lexiconDoc.defs).some((def: LexUserType) => 45 + def.type === "blob" || 46 + (def.type === "object" && 47 + Object.values((def as LexObject).properties || {}).some((prop) => 48 + "type" in prop && (prop.type === "blob" || 49 + (prop.type === "array" && "items" in prop && 50 + prop.items.type === "blob")) 51 + )) || 52 + (def.type === "array" && def.items.type === "blob") || 53 + // Check record schema for blobs 54 + (def.type === "record" && 55 + Object.values(def.record.properties || {}).some((prop) => 56 + "type" in prop && (prop.type === "blob" || 57 + (prop.type === "array" && "items" in prop && 58 + prop.items.type === "blob")) 59 + )) || 60 + // Check output schema for blobs 61 + (def.type === "query" || def.type === "procedure") && 62 + def.output?.schema?.type === "object" && 63 + Object.values(def.output.schema.properties || {}).some((prop) => 64 + "type" in prop && (prop.type === "blob" || 65 + (prop.type === "array" && "items" in prop && 66 + prop.items.type === "blob")) 67 + ) 68 + ); 69 + 70 + const needsCID = Object.values(lexiconDoc.defs).some((def: LexUserType) => 71 + def.type === "cid-link" || 72 + (def.type === "object" && 73 + Object.values((def as LexObject).properties || {}).some((prop) => 74 + "type" in prop && prop.type === "cid-link" 75 + )) || 76 + (def.type === "array" && def.items.type === "cid-link") || 77 + // Check record schema for cid-links 78 + (def.type === "record" && 79 + Object.values(def.record.properties || {}).some((prop) => 80 + "type" in prop && (prop.type === "cid-link" || 81 + (prop.type === "array" && "items" in prop && 82 + prop.items.type === "cid-link")) 83 + )) || 84 + // Check output schema for cid-links 85 + (def.type === "query" || def.type === "procedure") && 86 + def.output?.schema?.type === "object" && 87 + Object.values(def.output.schema.properties || {}).some((prop) => 88 + "type" in prop && (prop.type === "cid-link" || 89 + (prop.type === "array" && "items" in prop && 90 + prop.items.type === "cid-link")) 91 + ) 92 + ); 93 + 94 + const needsTypedValidation = Object.values(lexiconDoc.defs).some(( 95 + def: LexUserType, 96 + ) => def.type === "record" || def.type === "object"); 97 + 98 + const needsId = Object.values(lexiconDoc.defs).some(( 99 + def: LexUserType, 100 + ) => def.type === "token") || needsTypedValidation; 101 + 102 + const needsUnionType = Object.values(lexiconDoc.defs).some( 103 + (def: LexUserType) => { 104 + // Check direct array unions 105 + if (def.type === "array" && def.items.type === "union") return true; 106 + 107 + // Check object property unions 108 + if (def.type === "object") { 109 + return Object.values((def as LexObject).properties || {}).some((prop) => 110 + prop.type === "union" || 111 + (prop.type === "array" && prop.items?.type === "union") 112 + ); 113 + } 114 + 115 + // Check record property unions 116 + if (def.type === "record") { 117 + return Object.values(def.record.properties || {}).some((prop) => 118 + "type" in prop && ( 119 + prop.type === "union" || 120 + (prop.type === "array" && "items" in prop && 121 + prop.items.type === "union") 122 + ) 123 + ); 124 + } 125 + 126 + // Check procedure input/output schemas 127 + if (def.type === "procedure") { 128 + // Check input schema 129 + if (def.input?.schema?.type === "union") return true; 130 + if (def.input?.schema?.type === "object") { 131 + return Object.values(def.input.schema.properties || {}).some((prop) => 132 + "type" in prop && ( 133 + prop.type === "union" || 134 + (prop.type === "array" && "items" in prop && 135 + prop.items.type === "union") 136 + ) 137 + ); 138 + } 139 + // Check output schema 140 + if (def.output?.schema?.type === "union") return true; 141 + if (def.output?.schema?.type === "object") { 142 + return Object.values(def.output.schema.properties || {}).some(( 143 + prop, 144 + ) => 145 + "type" in prop && ( 146 + prop.type === "union" || 147 + (prop.type === "array" && "items" in prop && 148 + prop.items.type === "union") 149 + ) 150 + ); 151 + } 152 + } 153 + 154 + // Check query output schemas 155 + if (def.type === "query") { 156 + if (def.output?.schema?.type === "union") return true; 157 + if (def.output?.schema?.type === "object") { 158 + return Object.values(def.output.schema.properties || {}).some(( 159 + prop, 160 + ) => 161 + "type" in prop && ( 162 + prop.type === "union" || 163 + (prop.type === "array" && "items" in prop && 164 + prop.items.type === "union") 165 + ) 166 + ); 167 + } 168 + } 169 + 170 + // Check subscription message schemas 171 + if (def.type === "subscription") { 172 + if (def.message?.schema?.type === "union") return true; 173 + if (def.message?.schema?.type === "object") { 174 + return Object.values(def.message.schema.properties || {}).some(( 175 + prop, 176 + ) => 177 + "type" in prop && ( 178 + prop.type === "union" || 179 + (prop.type === "array" && "items" in prop && 180 + prop.items.type === "union") 181 + ) 182 + ); 183 + } 184 + } 185 + 186 + return false; 187 + }, 188 + ); 189 + 190 + //= import {BlobRef} from '@atp/lexicon' 191 + if (needsBlobRef) { 192 + file.addImportDeclaration({ 193 + isTypeOnly: true, 194 + moduleSpecifier: "@atp/lexicon", 195 + namedImports: [{ name: "BlobRef" }], 196 + }); 197 + } 198 + 199 + //= import {CID} from 'multiformats/cid' 200 + if (needsCID) { 201 + file.addImportDeclaration({ 202 + isTypeOnly: true, 203 + moduleSpecifier: "multiformats/cid", 204 + namedImports: [{ name: "CID" }], 205 + }); 206 + } 207 + 208 + const utilPath = `${ 209 + baseNsid 210 + .split(".") 211 + .map((_str) => "..") 212 + .join("/") 213 + }/util${importExtension}`; 214 + 215 + if (needsTypedValidation) { 216 + //= import { validate as _validate } from '../../lexicons.ts' 217 + file 218 + .addImportDeclaration({ 219 + moduleSpecifier: `${ 220 + baseNsid 221 + .split(".") 222 + .map((_str) => "..") 223 + .join("/") 224 + }/lexicons${importExtension}`, 225 + }) 226 + .addNamedImports([{ name: "validate", alias: "_validate" }]); 227 + 228 + //= import type { ValidationResult } from '@atp/lexicon' 229 + file.addImportDeclaration({ 230 + isTypeOnly: true, 231 + moduleSpecifier: "@atp/lexicon", 232 + namedImports: [{ name: "ValidationResult" }], 233 + }); 234 + 235 + // tsc adds protection against circular imports, which hurts bundle size. 236 + // Since we know that lexicon.ts and util.ts do not depend on the file being 237 + // generated, we can safely bypass this protection. 238 + // Note that we are not using `import * as util from '../../util'` because 239 + // typescript will emit is own helpers for the import, which we want to avoid. 240 + file.addVariableStatement({ 241 + isExported: false, 242 + declarationKind: VariableDeclarationKind.Const, 243 + declarations: [ 244 + { name: "is$typed", initializer: "_is$typed" }, 245 + { name: "validate", initializer: "_validate" }, 246 + ], 247 + }); 248 + } 249 + 250 + const utilImports: Array< 251 + { name: string; alias?: string; isTypeOnly?: boolean } 252 + > = []; 253 + if (needsTypedValidation) { 254 + utilImports.push({ name: "is$typed", alias: "_is$typed" }); 255 + } 256 + if (needsUnionType) { 257 + utilImports.push({ name: "$Typed", isTypeOnly: true }); 258 + } 259 + 260 + if (utilImports.length > 0) { 261 + const allTypeOnly = utilImports.every((imp) => imp.isTypeOnly); 262 + if (allTypeOnly) { 263 + file.addImportDeclaration({ 264 + isTypeOnly: true, 265 + moduleSpecifier: utilPath, 266 + namedImports: utilImports.map((imp) => ({ 267 + name: imp.name, 268 + alias: imp.alias, 269 + })), 270 + }); 271 + } else { 272 + file 273 + .addImportDeclaration({ 274 + moduleSpecifier: utilPath, 275 + }) 276 + .addNamedImports(utilImports); 277 + } 278 + } 279 + 280 + if (needsId) { 281 + //= const id = "{baseNsid}" 282 + file.addVariableStatement({ 283 + isExported: false, // Do not export to allow tree-shaking 284 + declarationKind: VariableDeclarationKind.Const, 285 + declarations: [{ name: "id", initializer: JSON.stringify(baseNsid) }], 286 + }); 287 + } 288 + } 289 + 290 + export function collectExternalImports( 291 + lexiconDocs: LexiconDoc[], 292 + options?: CodeGenOptions, 293 + ): Map<string, Set<string>> { 294 + const imports: Map<string, Set<string>> = new Map(); 295 + const mappings = options?.mappings; 296 + 297 + // Check if any records exist (which use ATP_METHODS) 298 + const hasRecords = lexiconDocs.some((lexiconDoc) => 299 + Object.values(lexiconDoc.defs).some((def) => def.type === "record") 300 + ); 301 + 302 + // Record classes use ATP_METHODS which may need external imports 303 + // Note: put is commented out in genRecordCls, so we don't import it 304 + if (hasRecords) { 305 + const atpMethods = [ 306 + "com.atproto.repo.listRecords", 307 + "com.atproto.repo.getRecord", 308 + "com.atproto.repo.createRecord", 309 + "com.atproto.repo.deleteRecord", 310 + ]; 311 + for (const methodNsid of atpMethods) { 312 + const mapping = resolveExternalImport(methodNsid, mappings); 313 + if (mapping) { 314 + if (!imports.has(methodNsid)) { 315 + imports.set(methodNsid, new Set()); 316 + } 317 + // These methods use QueryParams, InputSchema, etc. 318 + imports.get(methodNsid)!.add("main"); 319 + } 320 + } 321 + } 322 + return imports; 323 + } 324 + 325 + export function genImports( 326 + file: SourceFile, 327 + imports: Map<string, Set<string>>, 328 + baseNsid: string, 329 + options?: CodeGenOptions, 330 + ) { 331 + const startPath = "/" + baseNsid.split(".").slice(0, -1).join("/"); 332 + const importExtension = options?.importSuffix ?? 333 + (options?.useJsExtension ? ".js" : ".ts"); 334 + const mappings = options?.mappings; 335 + 336 + for (const [nsid, types] of imports) { 337 + const mapping = resolveExternalImport(nsid, mappings); 338 + if (mapping) { 339 + if (typeof mapping.imports === "string") { 340 + file.addImportDeclaration({ 341 + isTypeOnly: true, 342 + moduleSpecifier: mapping.imports, 343 + namedImports: [{ name: toTitleCase(nsid), isTypeOnly: true }], 344 + }); 345 + } else { 346 + const result = mapping.imports(nsid); 347 + if (result.type === "namespace") { 348 + file.addImportDeclaration({ 349 + isTypeOnly: true, 350 + moduleSpecifier: result.from, 351 + namespaceImport: toTitleCase(nsid), 352 + }); 353 + } else { 354 + const namedImports = Array.from(types).map((typeName) => ({ 355 + name: toTitleCase(typeName), 356 + isTypeOnly: true, 357 + })); 358 + file.addImportDeclaration({ 359 + isTypeOnly: true, 360 + moduleSpecifier: result.from, 361 + namedImports, 362 + }); 363 + } 364 + } 365 + } else { 366 + const targetPath = "/" + nsid.split(".").join("/") + importExtension; 367 + let resolvedPath = getRelativePath(startPath, targetPath); 368 + if (!resolvedPath.startsWith(".")) { 369 + resolvedPath = `./${resolvedPath}`; 370 + } 371 + file.addImportDeclaration({ 372 + isTypeOnly: true, 373 + moduleSpecifier: resolvedPath, 374 + namespaceImport: toTitleCase(nsid), 375 + }); 376 + } 377 + } 378 + } 379 + 380 + export function genUserType( 381 + file: SourceFile, 382 + imports: Map<string, Set<string>>, 383 + lexicons: Lexicons, 384 + lexUri: string, 385 + options?: CodeGenOptions, 386 + ) { 387 + const def = lexicons.getDefOrThrow(lexUri); 388 + switch (def.type) { 389 + case "array": 390 + genArray(file, imports, lexUri, def, options); 391 + break; 392 + case "token": 393 + genToken(file, lexUri, def); 394 + break; 395 + case "object": { 396 + const ifaceName: string = toTitleCase(getHash(lexUri)); 397 + genObject(file, imports, lexUri, def, ifaceName, { 398 + typeProperty: true, 399 + }, options); 400 + genObjHelpers(file, lexUri, ifaceName, { 401 + requireTypeProperty: false, 402 + }); 403 + break; 404 + } 405 + 406 + case "blob": 407 + case "bytes": 408 + case "cid-link": 409 + case "boolean": 410 + case "integer": 411 + case "string": 412 + case "unknown": 413 + genPrimitiveOrBlob(file, lexUri, def); 414 + break; 415 + 416 + default: 417 + throw new Error( 418 + `genLexUserType() called with wrong definition type (${def.type}) in ${lexUri}`, 419 + ); 420 + } 421 + } 422 + 423 + function genObject( 424 + file: SourceFile, 425 + imports: Map<string, Set<string>>, 426 + lexUri: string, 427 + def: LexObject, 428 + ifaceName: string, 429 + { 430 + defaultsArePresent = true, 431 + allowUnknownProperties = false, 432 + typeProperty = false, 433 + }: { 434 + defaultsArePresent?: boolean; 435 + allowUnknownProperties?: boolean; 436 + typeProperty?: boolean | "required"; 437 + } = {}, 438 + options?: CodeGenOptions, 439 + ) { 440 + const iface = file.addInterface({ 441 + name: ifaceName, 442 + isExported: true, 443 + }); 444 + genComment(iface, def); 445 + 446 + if (typeProperty) { 447 + const hash = getHash(lexUri); 448 + const baseNsid = stripScheme(stripHash(lexUri)); 449 + 450 + //= $type?: <uri> 451 + iface.addProperty({ 452 + name: typeProperty === "required" ? `$type` : `$type?`, 453 + type: 454 + // Not using $Type here because it is less readable than a plain string 455 + // `$Type<${JSON.stringify(baseNsid)}, ${JSON.stringify(hash)}>` 456 + hash === "main" 457 + ? JSON.stringify(`${baseNsid}`) 458 + : JSON.stringify(`${baseNsid}#${hash}`), 459 + }); 460 + } 461 + 462 + const nullableProps = new Set(def.nullable); 463 + if (def.properties) { 464 + for (const propKey in def.properties) { 465 + const propDef = def.properties[propKey]; 466 + const propNullable = nullableProps.has(propKey); 467 + const req = def.required?.includes(propKey) || 468 + (defaultsArePresent && 469 + "default" in propDef && 470 + propDef.default !== undefined); 471 + if (propDef.type === "ref" || propDef.type === "union") { 472 + //= propName: External|External 473 + const types = propDef.type === "union" 474 + ? propDef.refs.map((ref) => 475 + refToUnionType(ref, lexUri, imports, options?.mappings) 476 + ) 477 + : [ 478 + refToType( 479 + propDef.ref, 480 + stripScheme(stripHash(lexUri)), 481 + imports, 482 + options?.mappings, 483 + ), 484 + ]; 485 + if (propDef.type === "union" && !propDef.closed) { 486 + types.push("{ $type: string }"); 487 + } 488 + iface.addProperty({ 489 + name: `${propKey}${req ? "" : "?"}`, 490 + type: makeType(types, { nullable: propNullable }), 491 + }); 492 + continue; 493 + } else { 494 + if (propDef.type === "array") { 495 + //= propName: type[] 496 + let propAst; 497 + if (propDef.items.type === "ref") { 498 + propAst = iface.addProperty({ 499 + name: `${propKey}${req ? "" : "?"}`, 500 + type: makeType( 501 + refToType( 502 + propDef.items.ref, 503 + stripScheme(stripHash(lexUri)), 504 + imports, 505 + options?.mappings, 506 + ), 507 + { 508 + nullable: propNullable, 509 + array: true, 510 + }, 511 + ), 512 + }); 513 + } else if (propDef.items.type === "union") { 514 + const types = propDef.items.refs.map((ref) => 515 + refToUnionType(ref, lexUri, imports, options?.mappings) 516 + ); 517 + if (!propDef.items.closed) { 518 + types.push("{ $type: string }"); 519 + } 520 + propAst = iface.addProperty({ 521 + name: `${propKey}${req ? "" : "?"}`, 522 + type: makeType(types, { 523 + nullable: propNullable, 524 + array: true, 525 + }), 526 + }); 527 + } else { 528 + propAst = iface.addProperty({ 529 + name: `${propKey}${req ? "" : "?"}`, 530 + type: makeType(primitiveOrBlobToType(propDef.items), { 531 + nullable: propNullable, 532 + array: true, 533 + }), 534 + }); 535 + } 536 + genComment(propAst, propDef); 537 + } else { 538 + //= propName: type 539 + genComment( 540 + iface.addProperty({ 541 + name: `${propKey}${req ? "" : "?"}`, 542 + type: makeType(primitiveOrBlobToType(propDef), { 543 + nullable: propNullable, 544 + }), 545 + }), 546 + propDef, 547 + ); 548 + } 549 + } 550 + } 551 + 552 + if (allowUnknownProperties) { 553 + //= [k: string]: unknown 554 + iface.addIndexSignature({ 555 + keyName: "k", 556 + keyType: "string", 557 + returnType: "unknown", 558 + }); 559 + } 560 + } 561 + } 562 + 563 + export function genToken(file: SourceFile, lexUri: string, def: LexToken) { 564 + //= /** <comment> */ 565 + //= export const <TOKEN> = `${id}#<token>` 566 + genComment( 567 + file.addVariableStatement({ 568 + isExported: true, 569 + declarationKind: VariableDeclarationKind.Const, 570 + declarations: [ 571 + { 572 + name: toScreamingSnakeCase(getHash(lexUri)), 573 + type: "string", 574 + initializer: `\`\${id}#${getHash(lexUri)}\``, 575 + }, 576 + ], 577 + }), 578 + def, 579 + ); 580 + } 581 + 582 + export function genArray( 583 + file: SourceFile, 584 + imports: Map<string, Set<string>>, 585 + lexUri: string, 586 + def: LexArray, 587 + options?: CodeGenOptions, 588 + ) { 589 + if (def.items.type === "ref") { 590 + file.addTypeAlias({ 591 + name: toTitleCase(getHash(lexUri)), 592 + type: `${ 593 + refToType( 594 + def.items.ref, 595 + stripScheme(stripHash(lexUri)), 596 + imports, 597 + options?.mappings, 598 + ) 599 + }[]`, 600 + isExported: true, 601 + }); 602 + } else if (def.items.type === "union") { 603 + const types = def.items.refs.map((ref) => 604 + refToUnionType(ref, lexUri, imports, options?.mappings) 605 + ); 606 + if (!def.items.closed) { 607 + types.push("{ $type: string }"); 608 + } 609 + file.addTypeAlias({ 610 + name: toTitleCase(getHash(lexUri)), 611 + type: `(${types.join("|")})[]`, 612 + isExported: true, 613 + }); 614 + } else { 615 + genComment( 616 + file.addTypeAlias({ 617 + name: toTitleCase(getHash(lexUri)), 618 + type: `${primitiveOrBlobToType(def.items)}[]`, 619 + isExported: true, 620 + }), 621 + def, 622 + ); 623 + } 624 + } 625 + 626 + export function genPrimitiveOrBlob( 627 + file: SourceFile, 628 + lexUri: string, 629 + def: LexPrimitive | LexBlob | LexIpldType, 630 + ) { 631 + genComment( 632 + file.addTypeAlias({ 633 + name: toTitleCase(getHash(lexUri)), 634 + type: primitiveOrBlobToType(def), 635 + isExported: true, 636 + }), 637 + def, 638 + ); 639 + } 640 + 641 + export function genXrpcParams( 642 + file: SourceFile, 643 + lexicons: Lexicons, 644 + lexUri: string, 645 + defaultsArePresent = true, 646 + ) { 647 + const def = lexicons.getDefOrThrow(lexUri, [ 648 + "query", 649 + "subscription", 650 + "procedure", 651 + ]); 652 + 653 + // @NOTE We need to use a `type` here instead of an `interface` because we 654 + // need the generated type to be used as generic type parameter like this: 655 + // 656 + // type QueryParams = {} // Generated by this function 657 + // 658 + // type MyUtil<P extends xrpcServer.QueryParam> = (...) 659 + // type NsType = MyUtil<NS.QueryParams> // ERROR if `NS.QueryParams` is an `interface` 660 + // 661 + // Second line will fail if `NS.QueryParams` is an `interface` that does 662 + // not explicitly extend `xrpcServer.QueryParam`, or have a string index 663 + // signature that encompasses `xrpcServer.QueryParam`. 664 + 665 + //= export type QueryParams = {...} 666 + if ( 667 + def.parameters && def.parameters.properties && 668 + Object.keys(def.parameters.properties).length > 0 669 + ) { 670 + genComment( 671 + file.addTypeAlias({ 672 + name: "QueryParams", 673 + isExported: true, 674 + type: `{ 675 + ${ 676 + Object.entries(def.parameters.properties) 677 + .map(([paramKey, paramDef]) => { 678 + const req = def.parameters!.required?.includes(paramKey) || 679 + (defaultsArePresent && 680 + "default" in paramDef && 681 + paramDef.default !== undefined); 682 + const jsDoc = paramDef.description 683 + ? `/** ${paramDef.description} */\n` 684 + : ""; 685 + return `${jsDoc}${paramKey}${req ? "" : "?"}: ${ 686 + paramDef.type === "array" 687 + ? primitiveToType(paramDef.items) + "[]" 688 + : primitiveToType(paramDef) 689 + }`; 690 + }) 691 + .join("\n") 692 + } 693 + }`, 694 + }), 695 + def.parameters, 696 + ); 697 + } else { 698 + file.addTypeAlias({ 699 + name: "QueryParams", 700 + isExported: true, 701 + type: "globalThis.Record<PropertyKey, never>", 702 + }); 703 + } 704 + } 705 + 706 + export function genXrpcInput( 707 + file: SourceFile, 708 + imports: Map<string, Set<string>>, 709 + lexicons: Lexicons, 710 + lexUri: string, 711 + defaultsArePresent = true, 712 + options?: CodeGenOptions, 713 + ) { 714 + const def = lexicons.getDefOrThrow(lexUri, ["query", "procedure"]); 715 + 716 + if (def.type === "procedure" && def.input?.schema) { 717 + if (def.input.schema.type === "ref" || def.input.schema.type === "union") { 718 + //= export type InputSchema = ... 719 + 720 + const types = def.input.schema.type === "union" 721 + ? def.input.schema.refs.map((ref) => 722 + refToUnionType(ref, lexUri, imports, options?.mappings) 723 + ) 724 + : [ 725 + refToType( 726 + def.input.schema.ref, 727 + stripScheme(stripHash(lexUri)), 728 + imports, 729 + options?.mappings, 730 + ), 731 + ]; 732 + 733 + if (def.input.schema.type === "union" && !def.input.schema.closed) { 734 + types.push("{ $type: string }"); 735 + } 736 + file.addTypeAlias({ 737 + name: "InputSchema", 738 + type: types.join("|"), 739 + isExported: true, 740 + }); 741 + } else { 742 + //= export interface InputSchema {...} 743 + genObject(file, imports, lexUri, def.input.schema, `InputSchema`, { 744 + defaultsArePresent, 745 + }, options); 746 + } 747 + } else if (def.type === "procedure" && def.input?.encoding) { 748 + //= export type InputSchema = string | Uint8Array | Blob 749 + file.addTypeAlias({ 750 + isExported: true, 751 + name: "InputSchema", 752 + type: "string | Uint8Array | Blob", 753 + }); 754 + } else { 755 + //= export type InputSchema = undefined 756 + file.addTypeAlias({ 757 + isExported: true, 758 + name: "InputSchema", 759 + type: "undefined", 760 + }); 761 + } 762 + } 763 + 764 + export function genXrpcOutput( 765 + file: SourceFile, 766 + imports: Map<string, Set<string>>, 767 + lexicons: Lexicons, 768 + lexUri: string, 769 + defaultsArePresent = true, 770 + options?: CodeGenOptions, 771 + ) { 772 + const def = lexicons.getDefOrThrow(lexUri, [ 773 + "query", 774 + "subscription", 775 + "procedure", 776 + ]); 777 + 778 + const schema = def.type === "subscription" 779 + ? def.message?.schema 780 + : def.output?.schema; 781 + if (schema) { 782 + if (schema.type === "ref" || schema.type === "union") { 783 + //= export type OutputSchema = ... 784 + const types = schema.type === "union" 785 + ? schema.refs.map((ref) => 786 + refToUnionType(ref, lexUri, imports, options?.mappings) 787 + ) 788 + : [ 789 + refToType( 790 + schema.ref, 791 + stripScheme(stripHash(lexUri)), 792 + imports, 793 + options?.mappings, 794 + ), 795 + ]; 796 + if (schema.type === "union" && !schema.closed) { 797 + types.push("{ $type: string }"); 798 + } 799 + file.addTypeAlias({ 800 + name: "OutputSchema", 801 + type: types.join("|"), 802 + isExported: true, 803 + }); 804 + } else { 805 + // Check if schema is empty (no properties) 806 + const isEmpty = !schema.properties || 807 + Object.keys(schema.properties).length === 0; 808 + if (isEmpty) { 809 + //= export type OutputSchema = Record<PropertyKey, never> 810 + file.addTypeAlias({ 811 + name: "OutputSchema", 812 + type: "globalThis.Record<PropertyKey, never>", 813 + isExported: true, 814 + }); 815 + } else { 816 + //= export interface OutputSchema {...} 817 + genObject(file, imports, lexUri, schema, `OutputSchema`, { 818 + defaultsArePresent, 819 + }, options); 820 + } 821 + } 822 + } 823 + } 824 + 825 + export function genRecord( 826 + file: SourceFile, 827 + imports: Map<string, Set<string>>, 828 + lexicons: Lexicons, 829 + lexUri: string, 830 + options?: CodeGenOptions, 831 + ) { 832 + const def = lexicons.getDefOrThrow(lexUri, ["record"]); 833 + 834 + //= export interface Record {...} 835 + genObject(file, imports, lexUri, def.record, "Record", { 836 + defaultsArePresent: true, 837 + allowUnknownProperties: true, 838 + typeProperty: "required", 839 + }, options); 840 + 841 + //= export function isRecord(v: unknown): v is Record {...} 842 + genObjHelpers(file, lexUri, "Record", { 843 + requireTypeProperty: true, 844 + }); 845 + 846 + const hash = getHash(lexUri); 847 + if (hash === "main") { 848 + //= export type Main = Record 849 + file.addTypeAlias({ 850 + name: "Main", 851 + type: "Record", 852 + isExported: true, 853 + }); 854 + } 855 + } 856 + 857 + function genObjHelpers( 858 + file: SourceFile, 859 + lexUri: string, 860 + ifaceName: string, 861 + { 862 + requireTypeProperty, 863 + }: { 864 + requireTypeProperty: boolean; 865 + }, 866 + ) { 867 + const hash = getHash(lexUri); 868 + 869 + const hashVar = `hash${ifaceName}`; 870 + 871 + file.addVariableStatement({ 872 + isExported: false, 873 + declarationKind: VariableDeclarationKind.Const, 874 + declarations: [{ name: hashVar, initializer: JSON.stringify(hash) }], 875 + }); 876 + 877 + const isX = toCamelCase(`is-${ifaceName}`); 878 + 879 + //= export function is{X}<V>(v: V): v is {ifaceName} & V {...} 880 + file 881 + .addFunction({ 882 + name: isX, 883 + typeParameters: [{ name: `V` }], 884 + parameters: [{ name: `v`, type: `V` }], 885 + returnType: `v is ${ifaceName} & V`, 886 + isExported: true, 887 + }) 888 + .setBodyText(`return is$typed(v, id, ${hashVar})`); 889 + 890 + const validateX = toCamelCase(`validate-${ifaceName}`); 891 + 892 + //= export function validate{X}<V>(v: V): ValidationResult<{ifaceName} & V> {...} 893 + file 894 + .addFunction({ 895 + name: validateX, 896 + typeParameters: [{ name: `V` }], 897 + parameters: [{ name: `v`, type: `V` }], 898 + returnType: `ValidationResult<${ifaceName} & V>`, 899 + isExported: true, 900 + }) 901 + .setBodyText( 902 + `return validate<${ifaceName} & V>(v, id, ${hashVar}${ 903 + requireTypeProperty ? ", true" : "" 904 + })`, 905 + ); 906 + } 907 + 908 + export function stripScheme(uri: string): string { 909 + if (uri.startsWith("lex:")) return uri.slice(4); 910 + return uri; 911 + } 912 + 913 + export function stripHash(uri: string): string { 914 + return uri.split("#")[0] || ""; 915 + } 916 + 917 + export function getHash(uri: string): string { 918 + return uri.split("#").pop() || ""; 919 + } 920 + 921 + export function ipldToType(def: LexCidLink | LexBytes) { 922 + if (def.type === "bytes") { 923 + return "Uint8Array"; 924 + } 925 + return "CID"; 926 + } 927 + 928 + function refToUnionType( 929 + ref: string, 930 + lexUri: string, 931 + imports: Map<string, Set<string>>, 932 + mappings?: ImportMapping[], 933 + ): string { 934 + const baseNsid = stripScheme(stripHash(lexUri)); 935 + return `$Typed<${refToType(ref, baseNsid, imports, mappings)}>`; 936 + } 937 + 938 + export function resolveExternalImport( 939 + nsid: string, 940 + mappings?: ImportMapping[], 941 + ): ImportMapping | undefined { 942 + if (!mappings) return undefined; 943 + return mappings.find((mapping) => { 944 + return mapping.nsid.some((pattern) => { 945 + if (pattern.endsWith(".*")) { 946 + return nsid.startsWith(pattern.slice(0, -1)); 947 + } 948 + return nsid === pattern; 949 + }); 950 + }); 951 + } 952 + 953 + function refToType( 954 + ref: string, 955 + baseNsid: string, 956 + imports: Map<string, Set<string>>, 957 + mappings?: ImportMapping[], 958 + ): string { 959 + let [refBase, refHash] = ref.split("#"); 960 + refBase = stripScheme(refBase); 961 + if (!refHash) refHash = "main"; 962 + 963 + // internal 964 + if (!refBase || baseNsid === refBase) { 965 + return toTitleCase(refHash); 966 + } 967 + 968 + // external - check if there's a mapping 969 + const mapping = resolveExternalImport(refBase, mappings); 970 + if (mapping) { 971 + if (!imports.has(refBase)) { 972 + imports.set(refBase, new Set()); 973 + } 974 + const types = imports.get(refBase)!; 975 + types.add(refHash); 976 + 977 + if (typeof mapping.imports === "string") { 978 + // String mapping means namespace import 979 + return `${toTitleCase(refBase)}.${toTitleCase(refHash)}`; 980 + } else { 981 + const result = mapping.imports(refBase); 982 + if (result.type === "namespace") { 983 + return `${toTitleCase(refBase)}.${toTitleCase(refHash)}`; 984 + } else { 985 + // Named import - return just the type name 986 + return toTitleCase(refHash); 987 + } 988 + } 989 + } 990 + 991 + // external - no mapping, use relative import 992 + if (!imports.has(refBase)) { 993 + imports.set(refBase, new Set()); 994 + } 995 + return `${toTitleCase(refBase)}.${toTitleCase(refHash)}`; 996 + } 997 + 998 + export function primitiveOrBlobToType( 999 + def: LexBlob | LexPrimitive | LexIpldType, 1000 + ): string { 1001 + switch (def.type) { 1002 + case "blob": 1003 + return "BlobRef"; 1004 + case "bytes": 1005 + return "Uint8Array"; 1006 + case "cid-link": 1007 + return "CID"; 1008 + default: 1009 + return primitiveToType(def); 1010 + } 1011 + } 1012 + 1013 + export function primitiveToType(def: LexPrimitive): string { 1014 + switch (def.type) { 1015 + case "string": 1016 + if (def.knownValues?.length) { 1017 + return `${ 1018 + def.knownValues 1019 + .map((v) => JSON.stringify(v)) 1020 + .join(" | ") 1021 + } | (string & globalThis.Record<PropertyKey, never>)`; 1022 + } else if (def.enum) { 1023 + return def.enum.map((v) => JSON.stringify(v)).join(" | "); 1024 + } else if (def.const) { 1025 + return JSON.stringify(def.const); 1026 + } 1027 + return "string"; 1028 + case "integer": 1029 + if (def.enum) { 1030 + return def.enum.map((v) => JSON.stringify(v)).join(" | "); 1031 + } else if (def.const) { 1032 + return JSON.stringify(def.const); 1033 + } 1034 + return "number"; 1035 + case "boolean": 1036 + if (def.const) { 1037 + return JSON.stringify(def.const); 1038 + } 1039 + return "boolean"; 1040 + case "unknown": 1041 + // @TODO Should we use "object" here ? 1042 + // the "Record" identifier from typescript get overwritten by the Record 1043 + // interface created by lex-cli. 1044 + return "{ [_ in string]: unknown }"; // Record<string, unknown> 1045 + default: 1046 + throw new Error(`Unexpected primitive type: ${JSON.stringify(def)}`); 1047 + } 1048 + } 1049 + 1050 + function makeType( 1051 + _types: string | string[], 1052 + opts?: { array?: boolean; nullable?: boolean }, 1053 + ) { 1054 + const types = ([] as string[]).concat(_types); 1055 + if (opts?.nullable) types.push("null"); 1056 + const arr = opts?.array ? "[]" : ""; 1057 + if (types.length === 1) return `(${types[0]})${arr}`; 1058 + if (arr) return `(${types.join(" | ")})${arr}`; 1059 + return types.join(" | "); 1060 + }
+503
lex-gen/codegen/server.ts
··· 1 + import { 2 + IndentationText, 3 + Project, 4 + type SourceFile, 5 + VariableDeclarationKind, 6 + } from "ts-morph"; 7 + import { type LexiconDoc, Lexicons } from "@atp/lexicon"; 8 + import { NSID } from "@atp/syntax"; 9 + import type { GeneratedAPI } from "../types.ts"; 10 + import { gen, lexiconsTs, utilTs } from "./common.ts"; 11 + import { 12 + collectExternalImports, 13 + genCommonImports, 14 + genImports, 15 + genRecord, 16 + genUserType, 17 + genXrpcInput, 18 + genXrpcOutput, 19 + genXrpcParams, 20 + resolveExternalImport, 21 + } from "./lex-gen.ts"; 22 + import { 23 + type CodeGenOptions, 24 + type DefTreeNode, 25 + lexiconsToDefTree, 26 + schemasToNsidTokens, 27 + toCamelCase, 28 + toScreamingSnakeCase, 29 + toTitleCase, 30 + } from "./util.ts"; 31 + 32 + export async function genServerApi( 33 + lexiconDocs: LexiconDoc[], 34 + options?: CodeGenOptions, 35 + ): Promise<GeneratedAPI> { 36 + const project = new Project({ 37 + useInMemoryFileSystem: true, 38 + manipulationSettings: { indentationText: IndentationText.TwoSpaces }, 39 + }); 40 + const api: GeneratedAPI = { files: [] }; 41 + const lexicons = new Lexicons(lexiconDocs); 42 + const nsidTree = lexiconsToDefTree(lexiconDocs); 43 + const nsidTokens = schemasToNsidTokens(lexiconDocs); 44 + for (const lexiconDoc of lexiconDocs) { 45 + api.files.push(await lexiconTs(project, lexicons, lexiconDoc, options)); 46 + } 47 + api.files.push(await utilTs(project)); 48 + api.files.push(await lexiconsTs(project, lexiconDocs)); 49 + api.files.push( 50 + await indexTs(project, lexiconDocs, nsidTree, nsidTokens, options), 51 + ); 52 + return api; 53 + } 54 + 55 + const indexTs = ( 56 + project: Project, 57 + lexiconDocs: LexiconDoc[], 58 + nsidTree: DefTreeNode[], 59 + nsidTokens: Record<string, string[]>, 60 + options?: CodeGenOptions, 61 + ) => 62 + gen(project, "/index.ts", (file) => { 63 + const importExtension = options?.importSuffix ?? 64 + (options?.useJsExtension ? ".js" : ".ts"); 65 + 66 + // Check if there are any subscription types 67 + const hasSubscriptions = lexiconDocs.some((doc) => 68 + doc.defs.main?.type === "subscription" 69 + ); 70 + 71 + //= import {createServer as createXrpcServer, Server as XrpcServer} from '@atp/xrpc-server' 72 + const namedImports = [ 73 + { name: "Auth", isTypeOnly: true }, 74 + { name: "Options", alias: "XrpcOptions", isTypeOnly: true }, 75 + { name: "Server", alias: "XrpcServer", isTypeOnly: true }, 76 + { name: "MethodConfigOrHandler", isTypeOnly: true }, 77 + { name: "createServer", alias: "createXrpcServer" }, 78 + ]; 79 + 80 + if (hasSubscriptions) { 81 + namedImports.splice(3, 0, { 82 + name: "StreamConfigOrHandler", 83 + isTypeOnly: true, 84 + }); 85 + } 86 + 87 + file.addImportDeclaration({ 88 + moduleSpecifier: "@atp/xrpc-server", 89 + namedImports, 90 + }); 91 + //= import {schemas} from './lexicons.ts' 92 + file 93 + .addImportDeclaration({ 94 + moduleSpecifier: `./lexicons${importExtension}`, 95 + }) 96 + .addNamedImport({ 97 + name: "schemas", 98 + }); 99 + 100 + // collect and import external lexicon references 101 + const externalImports = collectExternalImports(lexiconDocs, options); 102 + const mappings = options?.mappings; 103 + for (const [nsid, types] of externalImports) { 104 + const mapping = resolveExternalImport(nsid, mappings); 105 + if (mapping) { 106 + if (typeof mapping.imports === "string") { 107 + file.addImportDeclaration({ 108 + isTypeOnly: true, 109 + moduleSpecifier: mapping.imports, 110 + namedImports: [{ name: toTitleCase(nsid), isTypeOnly: true }], 111 + }); 112 + } else { 113 + const result = mapping.imports(nsid); 114 + if (result.type === "namespace") { 115 + file.addImportDeclaration({ 116 + isTypeOnly: true, 117 + moduleSpecifier: result.from, 118 + namespaceImport: toTitleCase(nsid), 119 + }); 120 + } else { 121 + const namedImports = Array.from(types).map((typeName) => ({ 122 + name: toTitleCase(typeName), 123 + isTypeOnly: true, 124 + })); 125 + file.addImportDeclaration({ 126 + isTypeOnly: true, 127 + moduleSpecifier: result.from, 128 + namedImports, 129 + }); 130 + } 131 + } 132 + } 133 + } 134 + 135 + // generate type imports 136 + for (const lexiconDoc of lexiconDocs) { 137 + if ( 138 + lexiconDoc.defs.main?.type !== "query" && 139 + lexiconDoc.defs.main?.type !== "subscription" && 140 + lexiconDoc.defs.main?.type !== "procedure" 141 + ) { 142 + continue; 143 + } 144 + file.addImportDeclaration({ 145 + isTypeOnly: true, 146 + moduleSpecifier: `./types/${ 147 + lexiconDoc.id.split(".").join("/") 148 + }${importExtension}`, 149 + namespaceImport: toTitleCase(lexiconDoc.id), 150 + }); 151 + } 152 + 153 + // generate token enums 154 + for (const nsidAuthority in nsidTokens) { 155 + // export const {THE_AUTHORITY} = { 156 + // {Name}: "{authority.the.name}" 157 + // } 158 + file.addVariableStatement({ 159 + isExported: true, 160 + declarationKind: VariableDeclarationKind.Const, 161 + declarations: [ 162 + { 163 + name: toScreamingSnakeCase(nsidAuthority), 164 + initializer: [ 165 + "{", 166 + ...nsidTokens[nsidAuthority].map( 167 + (nsidName) => 168 + `${toTitleCase(nsidName)}: "${nsidAuthority}.${nsidName}",`, 169 + ), 170 + "}", 171 + ].join("\n"), 172 + }, 173 + ], 174 + }); 175 + } 176 + 177 + //= export function createServer(options?: XrpcOptions) { ... } 178 + const createServerFn = file.addFunction({ 179 + name: "createServer", 180 + returnType: "Server", 181 + parameters: [ 182 + { name: "options", type: "XrpcOptions", hasQuestionToken: true }, 183 + ], 184 + isExported: true, 185 + }); 186 + createServerFn.setBodyText(`return new Server(options)`); 187 + 188 + //= export class Server {...} 189 + const serverCls = file.addClass({ 190 + name: "Server", 191 + isExported: true, 192 + }); 193 + //= xrpc: XrpcServer = createXrpcServer(methodSchemas) 194 + serverCls.addProperty({ 195 + name: "xrpc", 196 + type: "XrpcServer", 197 + }); 198 + 199 + // generate classes for the schemas 200 + for (const ns of nsidTree) { 201 + //= ns: NS 202 + serverCls.addProperty({ 203 + name: ns.propName, 204 + type: ns.className, 205 + }); 206 + 207 + // class... 208 + genNamespaceCls(file, ns); 209 + } 210 + 211 + //= constructor (options?: XrpcOptions) { 212 + //= this.xrpc = createXrpcServer(schemas, options) 213 + //= {namespace declarations} 214 + //= } 215 + serverCls 216 + .addConstructor({ 217 + parameters: [ 218 + { name: "options", type: "XrpcOptions", hasQuestionToken: true }, 219 + ], 220 + }) 221 + .setBodyText( 222 + [ 223 + "this.xrpc = createXrpcServer(schemas, options)", 224 + ...nsidTree.map( 225 + (ns) => `this.${ns.propName} = new ${ns.className}(this)`, 226 + ), 227 + ].join("\n"), 228 + ); 229 + }); 230 + 231 + function genNamespaceCls(file: SourceFile, ns: DefTreeNode) { 232 + //= export class {ns}NS {...} 233 + const cls = file.addClass({ 234 + name: ns.className, 235 + isExported: true, 236 + }); 237 + //= _server: Server 238 + cls.addProperty({ 239 + name: "_server", 240 + type: "Server", 241 + }); 242 + 243 + for (const child of ns.children) { 244 + //= child: ChildNS 245 + cls.addProperty({ 246 + name: child.propName, 247 + type: child.className, 248 + }); 249 + 250 + // recurse 251 + genNamespaceCls(file, child); 252 + } 253 + 254 + //= constructor(server: Server) { 255 + //= this._server = server 256 + //= {child namespace declarations} 257 + //= } 258 + const cons = cls.addConstructor(); 259 + cons.addParameter({ 260 + name: "server", 261 + type: "Server", 262 + }); 263 + cons.setBodyText( 264 + [ 265 + `this._server = server`, 266 + ...ns.children.map( 267 + (ns) => `this.${ns.propName} = new ${ns.className}(server)`, 268 + ), 269 + ].join("\n"), 270 + ); 271 + 272 + // methods 273 + for (const userType of ns.userTypes) { 274 + if ( 275 + userType.def.type !== "query" && 276 + userType.def.type !== "subscription" && 277 + userType.def.type !== "procedure" 278 + ) { 279 + continue; 280 + } 281 + const moduleName = toTitleCase(userType.nsid); 282 + const name = toCamelCase(NSID.parse(userType.nsid).name || ""); 283 + const isSubscription = userType.def.type === "subscription"; 284 + const method = cls.addMethod({ 285 + name, 286 + typeParameters: [ 287 + { 288 + name: "A", 289 + constraint: "Auth", 290 + default: "void", 291 + }, 292 + ], 293 + }); 294 + method.addParameter({ 295 + name: "cfg", 296 + type: isSubscription 297 + ? `StreamConfigOrHandler< 298 + A, 299 + ${moduleName}.QueryParams, 300 + ${moduleName}.HandlerOutput, 301 + >` 302 + : `MethodConfigOrHandler< 303 + A, 304 + ${moduleName}.QueryParams, 305 + ${moduleName}.HandlerInput, 306 + ${moduleName}.HandlerOutput, 307 + >`, 308 + }); 309 + const methodType = isSubscription ? "streamMethod" : "method"; 310 + method.setBodyText( 311 + [ 312 + `const nsid = '${userType.nsid}' // @ts-ignore - dynamically generated`, 313 + `return this._server.xrpc.${methodType}(nsid, cfg)`, 314 + ].join("\n"), 315 + ); 316 + } 317 + } 318 + 319 + const lexiconTs = ( 320 + project: Project, 321 + lexicons: Lexicons, 322 + lexiconDoc: LexiconDoc, 323 + options?: CodeGenOptions, 324 + ) => 325 + gen( 326 + project, 327 + `/types/${lexiconDoc.id.split(".").join("/")}.ts`, 328 + (file) => { 329 + const main = lexiconDoc.defs.main; 330 + if (main?.type === "query" || main?.type === "procedure") { 331 + const streamingInput = main?.type === "procedure" && 332 + main.input?.encoding && 333 + !main.input.schema; 334 + const streamingOutput = main.output?.encoding && !main.output.schema; 335 + if (streamingInput || streamingOutput) { 336 + //= ReadableStream is a web standard API 337 + // No import needed for ReadableStream 338 + } 339 + } 340 + 341 + genCommonImports(file, lexiconDoc.id, lexiconDoc); 342 + 343 + const imports: Map<string, Set<string>> = new Map(); 344 + for (const defId in lexiconDoc.defs) { 345 + const def = lexiconDoc.defs[defId]; 346 + const lexUri = `${lexiconDoc.id}#${defId}`; 347 + if (defId === "main") { 348 + if (def.type === "query" || def.type === "procedure") { 349 + genXrpcParams(file, lexicons, lexUri); 350 + genXrpcInput(file, imports, lexicons, lexUri, false, options); 351 + genXrpcOutput(file, imports, lexicons, lexUri, false, options); 352 + genServerXrpcMethod(file, lexicons, lexUri); 353 + } else if (def.type === "subscription") { 354 + genXrpcParams(file, lexicons, lexUri); 355 + genXrpcOutput(file, imports, lexicons, lexUri, false, options); 356 + genServerXrpcStreaming(file, lexicons, lexUri); 357 + } else if (def.type === "record") { 358 + genRecord(file, imports, lexicons, lexUri, options); 359 + } else { 360 + genUserType(file, imports, lexicons, lexUri, options); 361 + } 362 + } else { 363 + genUserType(file, imports, lexicons, lexUri, options); 364 + } 365 + } 366 + genImports(file, imports, lexiconDoc.id, options); 367 + }, 368 + ); 369 + 370 + function genServerXrpcMethod( 371 + file: SourceFile, 372 + lexicons: Lexicons, 373 + lexUri: string, 374 + ) { 375 + const def = lexicons.getDefOrThrow(lexUri, ["query", "procedure"]); 376 + 377 + //= export interface HandlerInput {...} 378 + if (def.type === "procedure" && def.input?.encoding) { 379 + const handlerInput = file.addInterface({ 380 + name: "HandlerInput", 381 + isExported: true, 382 + }); 383 + 384 + handlerInput.addProperty({ 385 + name: "encoding", 386 + type: def.input.encoding 387 + .split(",") 388 + .map((v) => `'${v.trim()}'`) 389 + .join(" | "), 390 + }); 391 + handlerInput.addProperty({ 392 + name: "body", 393 + type: def.input.schema 394 + ? def.input.encoding.includes(",") 395 + ? "InputSchema | ReadableStream" 396 + : "InputSchema" 397 + : "ReadableStream", 398 + }); 399 + } else { 400 + file.addTypeAlias({ 401 + isExported: true, 402 + name: "HandlerInput", 403 + type: "void", 404 + }); 405 + } 406 + 407 + // export interface HandlerSuccess {...} 408 + let hasHandlerSuccess = false; 409 + if (def.output?.schema || def.output?.encoding) { 410 + hasHandlerSuccess = true; 411 + const handlerSuccess = file.addInterface({ 412 + name: "HandlerSuccess", 413 + isExported: true, 414 + }); 415 + 416 + if (def.output.encoding) { 417 + handlerSuccess.addProperty({ 418 + name: "encoding", 419 + type: def.output.encoding 420 + .split(",") 421 + .map((v) => `'${v.trim()}'`) 422 + .join(" | "), 423 + }); 424 + } 425 + if (def.output?.schema) { 426 + if (def.output.encoding.includes(",")) { 427 + handlerSuccess.addProperty({ 428 + name: "body", 429 + type: "OutputSchema | Uint8Array | ReadableStream", 430 + }); 431 + } else { 432 + handlerSuccess.addProperty({ name: "body", type: "OutputSchema" }); 433 + } 434 + } else if (def.output?.encoding) { 435 + handlerSuccess.addProperty({ 436 + name: "body", 437 + type: "Uint8Array | ReadableStream", 438 + }); 439 + } 440 + handlerSuccess.addProperty({ 441 + name: "headers?", 442 + type: "{ [key: string]: string }", 443 + }); 444 + } 445 + 446 + // export interface HandlerError {...} 447 + const handlerError = file.addInterface({ 448 + name: "HandlerError", 449 + isExported: true, 450 + }); 451 + handlerError.addProperties([ 452 + { name: "status", type: "number" }, 453 + { name: "message?", type: "string" }, 454 + ]); 455 + if (def.errors?.length) { 456 + handlerError.addProperty({ 457 + name: "error?", 458 + type: def.errors.map((err) => `'${err.name}'`).join(" | "), 459 + }); 460 + } 461 + 462 + // export type HandlerOutput = ... 463 + file.addTypeAlias({ 464 + isExported: true, 465 + name: "HandlerOutput", 466 + type: `HandlerError | ${hasHandlerSuccess ? "HandlerSuccess" : "void"}`, 467 + }); 468 + } 469 + 470 + function genServerXrpcStreaming( 471 + file: SourceFile, 472 + lexicons: Lexicons, 473 + lexUri: string, 474 + ) { 475 + const def = lexicons.getDefOrThrow(lexUri, ["subscription"]); 476 + 477 + file.addImportDeclaration({ 478 + isTypeOnly: true, 479 + moduleSpecifier: "@atp/xrpc-server", 480 + namedImports: [{ name: "ErrorFrame" }], 481 + }); 482 + 483 + // export type HandlerError = ... 484 + file.addTypeAlias({ 485 + name: "HandlerError", 486 + isExported: true, 487 + type: `ErrorFrame<${arrayToUnion(def.errors?.map((e) => e.name))}>`, 488 + }); 489 + 490 + // export type HandlerOutput = ... 491 + file.addTypeAlias({ 492 + isExported: true, 493 + name: "HandlerOutput", 494 + type: `HandlerError | ${def.message?.schema ? "OutputSchema" : "void"}`, 495 + }); 496 + } 497 + 498 + function arrayToUnion(arr?: string[]) { 499 + if (!arr?.length) { 500 + return "never"; 501 + } 502 + return arr.map((item) => `'${item}'`).join(" | "); 503 + }
+108
lex-gen/codegen/util.ts
··· 1 + import type { LexiconDoc, LexUserType } from "@atp/lexicon"; 2 + import { NSID } from "@atp/syntax"; 3 + import type { ImportMapping } from "../types.ts"; 4 + 5 + export interface CodeGenOptions { 6 + useJsExtension?: boolean; 7 + importSuffix?: string; 8 + mappings?: ImportMapping[]; 9 + } 10 + 11 + export interface DefTreeNodeUserType { 12 + nsid: string; 13 + def: LexUserType; 14 + } 15 + 16 + export interface DefTreeNode { 17 + name: string; 18 + className: string; 19 + propName: string; 20 + children: DefTreeNode[]; 21 + userTypes: DefTreeNodeUserType[]; 22 + } 23 + 24 + export function lexiconsToDefTree(lexicons: LexiconDoc[]): DefTreeNode[] { 25 + const tree: DefTreeNode[] = []; 26 + for (const lexicon of lexicons) { 27 + if (!lexicon.defs.main) { 28 + continue; 29 + } 30 + const node = getOrCreateNode(tree, lexicon.id.split(".").slice(0, -1)); 31 + node.userTypes.push({ nsid: lexicon.id, def: lexicon.defs.main }); 32 + } 33 + return tree; 34 + } 35 + 36 + function getOrCreateNode(tree: DefTreeNode[], path: string[]): DefTreeNode { 37 + let node: DefTreeNode | undefined; 38 + for (let i = 0; i < path.length; i++) { 39 + const segment = path[i]; 40 + node = tree.find((v) => v.name === segment); 41 + if (!node) { 42 + node = { 43 + name: segment, 44 + className: `${toTitleCase(path.slice(0, i + 1).join("-"))}NS`, 45 + propName: toCamelCase(segment), 46 + children: [], 47 + userTypes: [], 48 + } as DefTreeNode; 49 + tree.push(node); 50 + } 51 + tree = node.children; 52 + } 53 + if (!node) throw new Error(`Invalid schema path: ${path.join(".")}`); 54 + return node; 55 + } 56 + 57 + export function schemasToNsidTokens( 58 + lexiconDocs: LexiconDoc[], 59 + ): Record<string, string[]> { 60 + const nsidTokens: Record<string, string[]> = {}; 61 + for (const lexiconDoc of lexiconDocs) { 62 + const nsidp = NSID.parse(lexiconDoc.id); 63 + if (!nsidp.name) continue; 64 + for (const defId in lexiconDoc.defs) { 65 + const def = lexiconDoc.defs[defId]; 66 + if (def.type !== "token") continue; 67 + const authority = nsidp.segments.slice(0, -1).join("."); 68 + nsidTokens[authority] ??= []; 69 + nsidTokens[authority].push( 70 + nsidp.name + (defId === "main" ? "" : `#${defId}`), 71 + ); 72 + } 73 + } 74 + return nsidTokens; 75 + } 76 + 77 + export function toTitleCase(v: string): string { 78 + v = v.replace(/^([a-z])/gi, (_, g) => g.toUpperCase()); // upper-case first letter 79 + v = v.replace(/[.#-]([a-z])/gi, (_, g) => g.toUpperCase()); // uppercase any dash, dot, or hash segments 80 + return v.replace(/[.-]/g, ""); // remove lefover dashes or dots 81 + } 82 + 83 + export function toCamelCase(v: string): string { 84 + v = v.replace(/[.#-]([a-z])/gi, (_, g) => g.toUpperCase()); // uppercase any dash, dot, or hash segments 85 + return v.replace(/[.-]/g, ""); // remove lefover dashes or dots 86 + } 87 + 88 + export function toScreamingSnakeCase(v: string): string { 89 + v = v.replace(/[.#-]+/gi, "_"); // convert dashes, dots, and hashes into underscores 90 + return v.toUpperCase(); // and scream! 91 + } 92 + 93 + export async function formatGeneratedFiles(outDir: string) { 94 + console.log("Formatting generated files..."); 95 + const cmd = new Deno.Command("deno", { 96 + args: ["fmt", outDir], 97 + cwd: Deno.cwd(), 98 + }); 99 + 100 + const { code, stderr } = await cmd.output(); 101 + 102 + if (code !== 0) { 103 + const errorMsg = new TextDecoder().decode(stderr); 104 + console.warn(`Warning: deno fmt failed: ${errorMsg}`); 105 + } else { 106 + console.log("Files formatted successfully."); 107 + } 108 + }
+142
lex-gen/config.ts
··· 1 + import { NSID } from "@atp/syntax"; 2 + import { parse } from "@std/jsonc"; 3 + import type { LexiconConfig } from "./types.ts"; 4 + 5 + function isValidLexiconPattern(pattern: string): boolean { 6 + if (pattern.endsWith(".*")) { 7 + try { 8 + NSID.parse(`${pattern.slice(0, -2)}.x`); 9 + return true; 10 + } catch { 11 + return false; 12 + } 13 + } 14 + return NSID.isValid(pattern); 15 + } 16 + 17 + function validateConfig(config: LexiconConfig): void { 18 + if (!config.outdir || config.outdir.length === 0) { 19 + throw new Error("outdir must not be empty"); 20 + } 21 + 22 + if (!config.files || config.files.length === 0) { 23 + throw new Error("files must include at least one glob pattern"); 24 + } 25 + 26 + for (const file of config.files) { 27 + if (!file || file.length === 0) { 28 + throw new Error("files must not contain empty strings"); 29 + } 30 + } 31 + 32 + if (config.mappings) { 33 + for (const mapping of config.mappings) { 34 + if (!mapping.nsid || mapping.nsid.length === 0) { 35 + throw new Error("mappings.nsid requires at least one pattern"); 36 + } 37 + 38 + for (const pattern of mapping.nsid) { 39 + if (!isValidLexiconPattern(pattern)) { 40 + throw new Error( 41 + `invalid NSID pattern: ${pattern} (must be valid NSID or end with .*)`, 42 + ); 43 + } 44 + } 45 + 46 + if (typeof mapping.imports === "string") { 47 + if (mapping.imports.length === 0) { 48 + throw new Error("mappings.imports must not be empty"); 49 + } 50 + } else if (typeof mapping.imports !== "function") { 51 + throw new Error("mappings.imports must be a string or function"); 52 + } 53 + } 54 + } 55 + 56 + if (config.modules?.importSuffix !== undefined) { 57 + if (config.modules.importSuffix.length === 0) { 58 + throw new Error("modules.importSuffix must not be empty"); 59 + } 60 + } 61 + 62 + if (config.pull) { 63 + if (!config.pull.outdir || config.pull.outdir.length === 0) { 64 + throw new Error("pull.outdir must not be empty"); 65 + } 66 + 67 + if (!config.pull.sources || config.pull.sources.length === 0) { 68 + throw new Error("pull.sources must include at least one source"); 69 + } 70 + 71 + for (const source of config.pull.sources) { 72 + if (source.type === "git") { 73 + if (!source.remote || source.remote.length === 0) { 74 + throw new Error("pull.sources[].remote must not be empty"); 75 + } 76 + 77 + if (source.ref !== undefined && source.ref.length === 0) { 78 + throw new Error("pull.sources[].ref must not be empty"); 79 + } 80 + 81 + if (!source.pattern || source.pattern.length === 0) { 82 + throw new Error( 83 + "pull.sources[].pattern must include at least one glob pattern", 84 + ); 85 + } 86 + 87 + for (const pattern of source.pattern) { 88 + if (!pattern || pattern.length === 0) { 89 + throw new Error( 90 + "pull.sources[].pattern must not contain empty strings", 91 + ); 92 + } 93 + } 94 + } 95 + } 96 + } 97 + } 98 + 99 + export function defineLexiconConfig(config: LexiconConfig): LexiconConfig { 100 + validateConfig(config); 101 + return config; 102 + } 103 + 104 + export async function loadLexiconConfig( 105 + configPath?: string, 106 + ): Promise<LexiconConfig | null> { 107 + if (!configPath) { 108 + const possiblePaths = [ 109 + "./lexicon.config.json", 110 + "./lexicon.config.jsonc", 111 + ]; 112 + for (const path of possiblePaths) { 113 + try { 114 + if (typeof Deno !== "undefined") { 115 + const stat = Deno.statSync(path); 116 + if (stat.isFile) { 117 + configPath = path; 118 + break; 119 + } 120 + } 121 + } catch { 122 + continue; 123 + } 124 + } 125 + } 126 + 127 + if (!configPath) { 128 + return null; 129 + } 130 + 131 + try { 132 + const content = typeof Deno !== "undefined" 133 + ? Deno.readTextFileSync(configPath) 134 + : (await import("node:fs")).readFileSync(configPath, "utf-8"); 135 + 136 + const parsed = parse(content) as unknown as LexiconConfig; 137 + return defineLexiconConfig(parsed); 138 + } catch (error) { 139 + console.warn(`Failed to load config from ${configPath}:`, error); 140 + return null; 141 + } 142 + }
+3 -1
lex-gen/deno.json
··· 9 9 "@std/fs": "jsr:@std/fs@^1.0.19", 10 10 "@std/jsonc": "jsr:@std/jsonc@^1.0.1", 11 11 "@std/path": "jsr:@std/path@^1.1.2", 12 - "ts-morph": "jsr:@ts-morph/ts-morph@^26.0.0" 12 + "prettier": "npm:prettier@^3.6.2", 13 + "ts-morph": "jsr:@ts-morph/ts-morph@^26.0.0", 14 + "zod": "jsr:@zod/zod@^4.1.11" 13 15 } 14 16 }
+78
lex-gen/mdgen/index.ts
··· 1 + import { readFileSync } from "@std/fs/unstable-read-file"; 2 + import { writeFileSync } from "@std/fs/unstable-write-file"; 3 + import type { LexiconDoc } from "@atp/lexicon"; 4 + 5 + const INSERT_START = [ 6 + "<!-- START lex generated content. Please keep comment here to allow auto update -->", 7 + "<!-- DON'T EDIT THIS SECTION! INSTEAD RE-RUN lex TO UPDATE -->", 8 + ]; 9 + const INSERT_END = [ 10 + "<!-- END lex generated TOC please keep comment here to allow auto update -->", 11 + ]; 12 + 13 + export async function process(outFilePath: string, lexicons: LexiconDoc[]) { 14 + let existingContent = ""; 15 + try { 16 + existingContent = new TextDecoder().decode(readFileSync(outFilePath)); 17 + } catch { 18 + // ignore - no existing content 19 + } 20 + const fileLines: StringTree = existingContent.split("\n"); 21 + 22 + // find previously generated content 23 + let startIndex = fileLines.findIndex((line) => matchesStart(line as string)); 24 + let endIndex = fileLines.findIndex((line) => matchesEnd(line as string)); 25 + if (startIndex === -1) { 26 + startIndex = fileLines.length; 27 + } 28 + if (endIndex === -1) { 29 + endIndex = fileLines.length; 30 + } 31 + 32 + // generate & insert content 33 + fileLines.splice(startIndex, endIndex - startIndex + 1, [ 34 + INSERT_START, 35 + await genMdLines(lexicons), 36 + INSERT_END, 37 + ]); 38 + 39 + writeFileSync(outFilePath, new TextEncoder().encode(merge(fileLines))); 40 + } 41 + 42 + function genMdLines(lexicons: LexiconDoc[]): StringTree { 43 + const doc: StringTree = []; 44 + for (const lexicon of lexicons) { 45 + console.log(lexicon.id); 46 + const desc: StringTree = []; 47 + if (lexicon.description) { 48 + desc.push(lexicon.description, ``); 49 + } 50 + doc.push([ 51 + `---`, 52 + ``, 53 + `## ${lexicon.id}`, 54 + "", 55 + desc, 56 + "```json", 57 + JSON.stringify(lexicon, null, 2), 58 + "```", 59 + ]); 60 + } 61 + return doc; 62 + } 63 + 64 + type StringTree = (StringTree | string | undefined)[]; 65 + function merge(arr: StringTree): string { 66 + return arr 67 + .flat(10) 68 + .filter((v) => typeof v === "string") 69 + .join("\n"); 70 + } 71 + 72 + function matchesStart(line: string) { 73 + return /<!-- START lex /.test(line); 74 + } 75 + 76 + function matchesEnd(line: string) { 77 + return /<!-- END lex /.test(line); 78 + }
+16 -2
lex-gen/mod.ts
··· 11 11 * 12 12 * @example 13 13 * ```bash 14 - * lex-gen build -i ./lexicons -o ./src/lexicons 14 + * lex-gen build -i ./lexicons -o ./lex 15 15 * ``` 16 16 * 17 17 * @module 18 18 */ 19 19 import { Command } from "@cliffy/command"; 20 - import { build } from "./cmd/index.ts"; 20 + import { build, genApi, genMd, genServer, genTsObj } from "./cmd/index.ts"; 21 + import { defineLexiconConfig, loadLexiconConfig } from "./config.ts"; 21 22 import process from "node:process"; 22 23 24 + export { defineLexiconConfig, loadLexiconConfig }; 23 25 export { build as buildCommand } from "./builder/mod.ts"; 24 26 export type { 25 27 LexBuilderLoadOptions, 26 28 LexBuilderOptions, 27 29 LexBuilderSaveOptions, 28 30 } from "./builder/mod.ts"; 31 + export type { 32 + GitSourceConfig, 33 + ImportMapping, 34 + LexiconConfig, 35 + ModulesConfig, 36 + PullConfig, 37 + SourceConfig, 38 + } from "./types.ts"; 29 39 30 40 const isDeno = typeof Deno !== "undefined"; 31 41 32 42 await new Command() 33 43 .name("lex-gen") 34 44 .description("Lexicon Generator") 45 + .command("api", genApi) 46 + .command("md", genMd) 47 + .command("server", genServer) 48 + .command("ts-obj", genTsObj) 35 49 .command("build", build) 36 50 .parse(isDeno ? Deno.args : process.argv.slice(2));
+163
lex-gen/pull.ts
··· 1 + import { join } from "@std/path"; 2 + import { existsSync } from "@std/fs"; 3 + import { removeSync } from "@std/fs/unstable-remove"; 4 + import { mkdirSync } from "@std/fs/unstable-mkdir"; 5 + import { readFileSync } from "@std/fs/unstable-read-file"; 6 + import { writeFileSync } from "@std/fs/unstable-write-file"; 7 + import { readDirSync } from "@std/fs/unstable-read-dir"; 8 + import { statSync } from "@std/fs/unstable-stat"; 9 + import { globToRegExp } from "@std/path"; 10 + import process from "node:process"; 11 + import type { PullConfig } from "./types.ts"; 12 + 13 + function copyMatchingFiles( 14 + sourceDir: string, 15 + targetBase: string, 16 + relativePath: string, 17 + regex: RegExp, 18 + ): void { 19 + try { 20 + if (!existsSync(sourceDir)) return; 21 + const entries = Array.from(readDirSync(sourceDir)); 22 + for (const entry of entries) { 23 + const sourcePath = join(sourceDir, entry.name); 24 + const relPath = relativePath 25 + ? join(relativePath, entry.name) 26 + : entry.name; 27 + const testPath = relPath.startsWith("/") ? relPath : `/${relPath}`; 28 + 29 + if (statSync(sourcePath).isDirectory) { 30 + copyMatchingFiles(sourcePath, targetBase, relPath, regex); 31 + } else if (entry.name.endsWith(".json")) { 32 + if (regex.test(testPath) || regex.test(relPath)) { 33 + const targetPath = join(targetBase, relPath); 34 + mkdirSync(join(targetPath, ".."), { recursive: true }); 35 + const content = readFileSync(sourcePath); 36 + writeFileSync(targetPath, content); 37 + } 38 + } 39 + } 40 + } catch { 41 + // skip 42 + } 43 + } 44 + 45 + export async function pullLexicons(config: PullConfig): Promise<void> { 46 + const cwd = typeof Deno !== "undefined" ? Deno.cwd() : process.cwd(); 47 + const pullDir = join(cwd, config.outdir); 48 + 49 + if (config.clean && existsSync(pullDir)) { 50 + console.log(`Cleaning ${pullDir}...`); 51 + removeSync(pullDir, { recursive: true }); 52 + } 53 + 54 + mkdirSync(pullDir, { recursive: true }); 55 + 56 + for (const source of config.sources) { 57 + if (source.type === "git") { 58 + await pullFromGit(source, pullDir); 59 + } 60 + } 61 + } 62 + 63 + export function cleanupPullDirectory(config: PullConfig): void { 64 + if (!config.clean) { 65 + return; 66 + } 67 + 68 + const cwd = typeof Deno !== "undefined" ? Deno.cwd() : process.cwd(); 69 + const pullDir = join(cwd, config.outdir); 70 + 71 + if (existsSync(pullDir)) { 72 + try { 73 + removeSync(pullDir, { recursive: true }); 74 + } catch { 75 + // ignore cleanup errors 76 + } 77 + } 78 + } 79 + 80 + async function pullFromGit( 81 + source: { remote: string; ref?: string; pattern: string[] }, 82 + targetDir: string, 83 + ): Promise<void> { 84 + const cwd = typeof Deno !== "undefined" ? Deno.cwd() : process.cwd(); 85 + const tempDir = join(cwd, ".lex-gen-temp", crypto.randomUUID()); 86 + 87 + try { 88 + console.log(`Cloning ${source.remote}...`); 89 + const cloneArgs = [ 90 + "clone", 91 + "--depth", 92 + "1", 93 + "--filter=blob:none", 94 + "--sparse", 95 + ]; 96 + 97 + if (source.ref) { 98 + cloneArgs.push(`--branch=${source.ref}`); 99 + } 100 + 101 + cloneArgs.push(source.remote, tempDir); 102 + 103 + const cloneCmd = new Deno.Command("git", { 104 + args: cloneArgs, 105 + cwd, 106 + }); 107 + 108 + const cloneResult = await cloneCmd.output(); 109 + if (!cloneResult.success) { 110 + const error = new TextDecoder().decode(cloneResult.stderr); 111 + throw new Error(`Failed to clone repository: ${error}`); 112 + } 113 + 114 + const sparseCheckoutCmd = new Deno.Command("git", { 115 + args: ["sparse-checkout", "set", "--no-cone", ...source.pattern], 116 + cwd: tempDir, 117 + }); 118 + 119 + const sparseResult = await sparseCheckoutCmd.output(); 120 + if (!sparseResult.success) { 121 + const error = new TextDecoder().decode(sparseResult.stderr); 122 + throw new Error(`Failed to set sparse checkout: ${error}`); 123 + } 124 + 125 + const checkoutCmd = new Deno.Command("git", { 126 + args: ["checkout"], 127 + cwd: tempDir, 128 + }); 129 + 130 + const checkoutResult = await checkoutCmd.output(); 131 + if (!checkoutResult.success) { 132 + const error = new TextDecoder().decode(checkoutResult.stderr); 133 + throw new Error(`Failed to checkout files: ${error}`); 134 + } 135 + 136 + for (const pattern of source.pattern) { 137 + const normalizedPattern = pattern.startsWith("./") 138 + ? pattern.slice(2) 139 + : pattern; 140 + const regex = globToRegExp(normalizedPattern, { 141 + extended: true, 142 + globstar: true, 143 + }); 144 + 145 + copyMatchingFiles(tempDir, targetDir, "", regex); 146 + } 147 + } finally { 148 + if (existsSync(tempDir)) { 149 + removeSync(tempDir, { recursive: true }); 150 + } 151 + const tempParent = join(cwd, ".lex-gen-temp"); 152 + if (existsSync(tempParent)) { 153 + try { 154 + const entries = Array.from(readDirSync(tempParent)); 155 + if (entries.length === 0) { 156 + removeSync(tempParent); 157 + } 158 + } catch { 159 + // ignore 160 + } 161 + } 162 + } 163 + }
+48
lex-gen/types.ts
··· 1 + export interface GeneratedFile { 2 + path: string; 3 + content: string; 4 + } 5 + 6 + export interface GeneratedAPI { 7 + files: GeneratedFile[]; 8 + } 9 + 10 + export interface FileDiff { 11 + act: "add" | "mod" | "del"; 12 + path: string; 13 + content?: string; 14 + } 15 + 16 + export interface GitSourceConfig { 17 + type: "git"; 18 + remote: string; 19 + ref?: string; 20 + pattern: string[]; 21 + } 22 + 23 + export type SourceConfig = GitSourceConfig; 24 + 25 + export interface PullConfig { 26 + outdir: string; 27 + clean?: boolean; 28 + sources: SourceConfig[]; 29 + } 30 + 31 + export interface ImportMapping { 32 + nsid: string[]; 33 + imports: 34 + | string 35 + | ((nsid: string) => { type: "named" | "namespace"; from: string }); 36 + } 37 + 38 + export interface ModulesConfig { 39 + importSuffix?: string; 40 + } 41 + 42 + export interface LexiconConfig { 43 + outdir: string; 44 + files: string[]; 45 + mappings?: ImportMapping[]; 46 + modules?: ModulesConfig; 47 + pull?: PullConfig; 48 + }
+290
lex-gen/util.ts
··· 1 + import { readFileSync } from "@std/fs/unstable-read-file"; 2 + import { statSync } from "@std/fs/unstable-stat"; 3 + import { mkdirSync } from "@std/fs/unstable-mkdir"; 4 + import { writeFileSync } from "@std/fs/unstable-write-file"; 5 + import { existsSync } from "@std/fs"; 6 + import { globToRegExp, join } from "@std/path"; 7 + import { removeSync } from "@std/fs/unstable-remove"; 8 + import { readDirSync } from "@std/fs/unstable-read-dir"; 9 + import { colors } from "@cliffy/ansi/colors"; 10 + import { ZodError } from "zod"; 11 + import { type LexiconDoc, parseLexiconDoc } from "@atp/lexicon"; 12 + import type { FileDiff, GeneratedAPI, LexiconConfig } from "./types.ts"; 13 + import process from "node:process"; 14 + 15 + type RecursiveZodError = { 16 + _errors?: string[]; 17 + [k: string]: RecursiveZodError | string[] | undefined; 18 + }; 19 + 20 + export function expandGlobPatterns(patterns: string[]): string[] { 21 + const files: string[] = []; 22 + const cwd = typeof Deno !== "undefined" ? Deno.cwd() : process.cwd(); 23 + 24 + function walkDir( 25 + dir: string, 26 + relativeToCwd: string, 27 + regex: RegExp, 28 + files: string[], 29 + ): void { 30 + try { 31 + if (!existsSync(dir)) return; 32 + const entries = Array.from(readDirSync(dir)); 33 + for (const entry of entries) { 34 + const fullPath = join(dir, entry.name); 35 + const relToCwd = relativeToCwd 36 + ? join(relativeToCwd, entry.name) 37 + : entry.name; 38 + if (statSync(fullPath).isDirectory) { 39 + walkDir(fullPath, relToCwd, regex, files); 40 + } else if (entry.name.endsWith(".json")) { 41 + const testPath = relToCwd.startsWith("/") ? relToCwd : `/${relToCwd}`; 42 + if (regex.test(testPath) || regex.test(relToCwd)) { 43 + files.push(fullPath); 44 + } 45 + } 46 + } 47 + } catch { 48 + // skip 49 + } 50 + } 51 + 52 + for (const pattern of patterns) { 53 + const normalizedPattern = pattern.startsWith("./") 54 + ? pattern.slice(2) 55 + : pattern; 56 + const regex = globToRegExp(normalizedPattern, { 57 + extended: true, 58 + globstar: true, 59 + }); 60 + const basePath = normalizedPattern.split("*")[0] || 61 + normalizedPattern.split("?")[0] || ""; 62 + let searchDir = cwd; 63 + let relativeToCwd = ""; 64 + if (basePath.includes("/")) { 65 + const lastSlashIndex = basePath.lastIndexOf("/"); 66 + if (lastSlashIndex >= 0) { 67 + const baseDir = basePath.substring(0, lastSlashIndex); 68 + searchDir = join(cwd, baseDir); 69 + relativeToCwd = baseDir; 70 + } 71 + } 72 + 73 + walkDir(searchDir, relativeToCwd, regex, files); 74 + } 75 + 76 + return Array.from(new Set(files)); 77 + } 78 + 79 + export function readAllLexicons(paths: string[] | string): LexiconDoc[] { 80 + const docs: LexiconDoc[] = []; 81 + const pathArray = Array.isArray(paths) ? paths : [paths]; 82 + const expandedPaths: string[] = []; 83 + 84 + for (const path of pathArray) { 85 + if (path.includes("*") || path.includes("?")) { 86 + expandedPaths.push(...expandGlobPatterns([path])); 87 + } else { 88 + expandedPaths.push(path); 89 + } 90 + } 91 + 92 + for (const path of expandedPaths) { 93 + if (statSync(path).isDirectory) { 94 + const entries = Array.from(readDirSync(path)); 95 + const subPaths = entries.map((entry) => join(path, entry.name)); 96 + docs.push(...readAllLexicons(subPaths)); 97 + } else if (path.endsWith(".json") && statSync(path).isFile) { 98 + try { 99 + docs.push(readLexicon(path)); 100 + } catch { 101 + // skip 102 + } 103 + } 104 + } 105 + return docs; 106 + } 107 + 108 + export function readLexicon(path: string): LexiconDoc { 109 + let str: string; 110 + let obj: unknown; 111 + try { 112 + str = new TextDecoder().decode(readFileSync(path)); 113 + } catch (e) { 114 + console.error(`Failed to read file`, path); 115 + throw e; 116 + } 117 + try { 118 + obj = JSON.parse(str); 119 + } catch (e) { 120 + console.error(`Failed to parse JSON in file`, path); 121 + throw e; 122 + } 123 + if ( 124 + obj && 125 + typeof obj === "object" && 126 + typeof (obj as LexiconDoc).lexicon === "number" 127 + ) { 128 + try { 129 + return parseLexiconDoc(obj); 130 + } catch (e) { 131 + console.error(`Invalid lexicon`, path); 132 + if (e instanceof ZodError) { 133 + printZodError(e.format()); 134 + } 135 + throw e; 136 + } 137 + } else { 138 + console.error(`Not lexicon schema`, path); 139 + throw new Error(`Not lexicon schema`); 140 + } 141 + } 142 + 143 + export function genTsObj(lexicons: LexiconDoc[]): string { 144 + return `export const lexicons = ${JSON.stringify(lexicons, null, 2)}`; 145 + } 146 + 147 + export function genFileDiff(outDir: string, api: GeneratedAPI) { 148 + const diffs: FileDiff[] = []; 149 + const existingFiles = readdirRecursiveSync(outDir); 150 + 151 + for (const file of api.files) { 152 + file.path = join(outDir, file.path); 153 + if (existingFiles.includes(file.path)) { 154 + diffs.push({ act: "mod", path: file.path, content: file.content }); 155 + } else { 156 + diffs.push({ act: "add", path: file.path, content: file.content }); 157 + } 158 + } 159 + for (const filepath of existingFiles) { 160 + if (api.files.find((f) => f.path === filepath)) { 161 + // do nothing 162 + } else { 163 + diffs.push({ act: "del", path: filepath }); 164 + } 165 + } 166 + 167 + return diffs; 168 + } 169 + 170 + export function printFileDiff(diff: FileDiff[]) { 171 + for (const d of diff) { 172 + switch (d.act) { 173 + case "add": 174 + console.log(`${colors.bold.green("[+ add]")} ${d.path}`); 175 + break; 176 + case "mod": 177 + console.log(`${colors.bold.yellow("[* mod]")} ${d.path}`); 178 + break; 179 + case "del": 180 + console.log(`${colors.bold.green("[- del]")} ${d.path}`); 181 + break; 182 + } 183 + } 184 + } 185 + 186 + export function applyFileDiff(diff: FileDiff[]) { 187 + for (const d of diff) { 188 + switch (d.act) { 189 + case "add": 190 + case "mod": 191 + mkdirSync(join(d.path, ".."), { recursive: true }); // lazy way to make sure the parent dir exists 192 + writeFileSync(d.path, new TextEncoder().encode(d.content || "")); 193 + break; 194 + case "del": 195 + removeSync(d.path); 196 + break; 197 + } 198 + } 199 + } 200 + 201 + function isRecursiveZodError(value: unknown): value is RecursiveZodError { 202 + return value !== null && typeof value === "object"; 203 + } 204 + 205 + function printZodError(node: RecursiveZodError, path = ""): boolean { 206 + if (node._errors?.length) { 207 + console.log(colors.red(`Issues at ${path}:`)); 208 + for (const err of dedup(node._errors)) { 209 + console.log(colors.red(` - ${err}`)); 210 + } 211 + return true; 212 + } else { 213 + for (const k in node) { 214 + if (k === "_errors") { 215 + continue; 216 + } 217 + const value = node[k]; 218 + if (isRecursiveZodError(value)) { 219 + printZodError(value, `${path}/${k}`); 220 + } 221 + } 222 + } 223 + return false; 224 + } 225 + 226 + function readdirRecursiveSync(root: string, files: string[] = [], prefix = "") { 227 + const dir = join(root, prefix); 228 + if (!existsSync(dir)) return files; 229 + if (statSync(dir).isDirectory) { 230 + Array.from(readDirSync(dir)).forEach(function (entry) { 231 + readdirRecursiveSync(root, files, join(prefix, entry.name)); 232 + }); 233 + } else if (prefix.endsWith(".ts")) { 234 + files.push(join(root, prefix)); 235 + } 236 + 237 + return files; 238 + } 239 + 240 + function dedup(arr: string[]): string[] { 241 + return Array.from(new Set(arr)); 242 + } 243 + 244 + export function shouldPullLexicons( 245 + config: LexiconConfig | null, 246 + filesProvidedViaCli: boolean, 247 + files: string[], 248 + ): boolean { 249 + if (!config?.pull) { 250 + return false; 251 + } 252 + 253 + if (filesProvidedViaCli) { 254 + return false; 255 + } 256 + 257 + const cwd = typeof Deno !== "undefined" ? Deno.cwd() : process.cwd(); 258 + 259 + for (const filePattern of files) { 260 + const normalizedPattern = filePattern.startsWith("./") 261 + ? filePattern.slice(2) 262 + : filePattern; 263 + const filePath = normalizedPattern.startsWith("/") 264 + ? normalizedPattern 265 + : join(cwd, normalizedPattern); 266 + 267 + if (filePattern.includes("*") || filePattern.includes("?")) { 268 + const expanded = expandGlobPatterns([filePattern]); 269 + if (expanded.length === 0) { 270 + return true; 271 + } 272 + let allExist = true; 273 + for (const file of expanded) { 274 + if (!existsSync(file)) { 275 + allExist = false; 276 + break; 277 + } 278 + } 279 + if (!allExist) { 280 + return true; 281 + } 282 + } else { 283 + if (!existsSync(filePath)) { 284 + return true; 285 + } 286 + } 287 + } 288 + 289 + return false; 290 + }
+37 -663
xrpc-server/server.ts
··· 1 1 import type { Context, Handler } from "hono"; 2 2 import { Hono } from "hono"; 3 - import { Procedure, Query, Subscription } from "@atp/lex"; 4 3 import { 5 4 type LexiconDoc, 6 5 Lexicons, ··· 28 27 type AuthResult, 29 28 type AuthVerifier, 30 29 type Awaitable, 31 - type FetchHandler, 32 30 type HandlerContext, 33 31 type HandlerSuccess, 34 32 type Input, 35 33 isHandlerPipeThroughBuffer, 36 34 isHandlerPipeThroughStream, 37 35 isSharedRateLimitOpts, 38 - type LexMethodConfig, 39 - type LexMethodConfigWithAuth, 40 - type LexMethodHandler, 41 - type LexSubscriptionConfig, 42 - type LexSubscriptionConfigWithAuth, 43 - type LexSubscriptionHandler, 44 36 type MethodConfig, 45 37 type MethodConfigOrHandler, 46 - type MethodConfigWithAuth, 47 38 type Options, 48 - type Output, 49 39 type Params, 50 40 type ServerRateLimitDescription, 51 41 type StreamConfig, 52 42 type StreamConfigOrHandler, 53 - type StreamConfigWithAuth, 54 43 } from "./types.ts"; 55 44 import { 56 45 asArray, ··· 83 72 * @param options - Optional server configuration options 84 73 */ 85 74 export function createServer( 86 - options?: Options, 87 - ): Server; 88 - export function createServer( 89 75 lexicons?: LexiconDoc[], 90 76 options?: Options, 91 - ): Server; 92 - export function createServer( 93 - lexiconsOrOptions?: LexiconDoc[] | Options, 94 - options?: Options, 95 77 ): Server { 96 - if (Array.isArray(lexiconsOrOptions)) { 97 - return new Server(lexiconsOrOptions, options); 98 - } 99 - return new Server(lexiconsOrOptions); 78 + return new Server(lexicons, options); 100 79 } 101 80 102 81 /** ··· 114 93 >(); 115 94 /** Lexicon registry for schema validation and method definitions */ 116 95 lex: Lexicons = new Lexicons(); 117 - handlers: Map<string, FetchHandler> = new Map(); 118 - methods: Map<string, Query | Procedure> = new Map(); 119 - streamMethods: Map<string, Subscription> = new Map(); 120 96 /** Server configuration options */ 121 97 options: Options; 122 98 /** Global rate limiter applied to all routes */ ··· 129 105 * @param lexicons - Optional array of lexicon documents to register 130 106 * @param opts - Server configuration options 131 107 */ 132 - constructor(options?: Options); 133 - constructor(lexicons?: LexiconDoc[], opts?: Options); 134 - constructor( 135 - lexiconsOrOptions?: LexiconDoc[] | Options, 136 - opts: Options = {}, 137 - ) { 108 + constructor(lexicons?: LexiconDoc[], opts: Options = {}) { 138 109 this.app = new Hono(); 139 - const lexicons = Array.isArray(lexiconsOrOptions) 140 - ? lexiconsOrOptions 141 - : undefined; 142 - this.options = Array.isArray(lexiconsOrOptions) 143 - ? opts 144 - : lexiconsOrOptions ?? {}; 110 + this.options = opts; 145 111 146 112 if (lexicons) { 147 113 this.addLexicons(lexicons); 148 114 } 149 115 150 116 this.app.use("*", this.catchall); 151 - this.app.onError(createErrorHandler(this.options)); 152 - this.app.get("/xrpc/_health", async (c) => { 153 - if (c.req.header("atproto-proxy") != null) { 154 - throw new InvalidRequestError( 155 - "atproto-proxy header is not allowed on health check endpoint", 156 - ); 157 - } 158 - const healthCheck = this.options.healthCheck; 159 - const data = healthCheck 160 - ? await healthCheck(c.req.raw) 161 - : { status: "ok" }; 162 - return c.json(data); 163 - }); 117 + this.app.onError(createErrorHandler(opts)); 164 118 165 119 this.app.notFound((c) => { 166 - if (!c.req.url.includes("/xrpc/") && this.options.fallback) { 167 - return this.options.fallback(c.req.raw) as Promise<Response> | Response; 168 - } 169 120 const nsid = parseUrlNsid(c.req.url); 170 121 if (nsid) { 171 - const def = this.getMethodDefinition(nsid); 122 + const def = this.lex.getDef(nsid); 172 123 if (def) { 173 124 const expectedMethod = def.type === "procedure" 174 125 ? "POST" ··· 189 140 return c.text("Not Found", 404); 190 141 }); 191 142 192 - const rateLimits = this.options.rateLimits; 193 - if (rateLimits) { 194 - const { global, shared, creator, bypass } = rateLimits; 143 + if (opts.rateLimits) { 144 + const { global, shared, creator, bypass } = opts.rateLimits; 195 145 196 146 if (global) { 197 147 this.globalRateLimiter = RouteRateLimiter.from( ··· 239 189 240 190 // handlers 241 191 242 - private getMainMethod<M extends Query | Procedure | Subscription>( 243 - methodOrNamespace: M | { main: M }, 244 - ): M { 245 - if ( 246 - typeof methodOrNamespace === "object" && 247 - methodOrNamespace !== null && 248 - "main" in methodOrNamespace 249 - ) { 250 - return methodOrNamespace.main; 251 - } 252 - return methodOrNamespace; 253 - } 254 - 255 - add< 256 - M extends Query | Procedure, 257 - A extends AuthResult, 258 - >( 259 - method: M | { main: M }, 260 - configOrFn: LexMethodConfigWithAuth<M, A>, 261 - ): this; 262 - add< 263 - M extends Query | Procedure, 264 - >( 265 - method: M | { main: M }, 266 - configOrFn: LexMethodConfig<M, void> | LexMethodHandler<M, void>, 267 - ): this; 268 - add< 269 - M extends Query | Procedure, 270 - A extends Auth, 271 - >( 272 - method: M | { main: M }, 273 - configOrFn: LexMethodConfig<M, A> | LexMethodHandler<M, A>, 274 - ): this; 275 - add< 276 - M extends Subscription, 277 - A extends AuthResult, 278 - >( 279 - method: M | { main: M }, 280 - configOrFn: LexSubscriptionConfigWithAuth<M, A>, 281 - ): this; 282 - add< 283 - M extends Subscription, 284 - >( 285 - method: M | { main: M }, 286 - configOrFn: 287 - | LexSubscriptionConfig<M, void> 288 - | LexSubscriptionHandler<M, void>, 289 - ): this; 290 - add< 291 - M extends Subscription, 292 - A extends Auth, 293 - >( 294 - method: M | { main: M }, 295 - configOrFn: LexSubscriptionConfig<M, A> | LexSubscriptionHandler<M, A>, 296 - ): this; 297 - add( 298 - method: 299 - | Query 300 - | Procedure 301 - | Subscription 302 - | { main: Query | Procedure | Subscription }, 303 - configOrFn: unknown, 304 - ): this { 305 - const main = this.getMainMethod( 306 - method as Query | Procedure | Subscription | { 307 - main: Query | Procedure | Subscription; 308 - }, 309 - ); 310 - if (this.handlers.has(main.nsid)) { 311 - throw new TypeError(`Method ${main.nsid} already registered`); 312 - } 313 - 314 - if (main instanceof Subscription) { 315 - this.addStreamMethod(main as any, configOrFn as any); 316 - } else { 317 - this.addMethod(main as any, configOrFn as any); 318 - } 319 - 320 - return this; 321 - } 322 - 323 192 /** 324 193 * Registers a method handler for the specified NSID. 325 194 * @param nsid - The namespace identifier for the method 326 195 * @param configOrFn - Either a handler function or full method configuration 327 196 */ 328 - method< 329 - M extends Query | Procedure, 330 - A extends AuthResult, 331 - >( 332 - method: M, 333 - configOrFn: LexMethodConfigWithAuth<M, A>, 334 - ): void; 335 - method< 336 - M extends Query | Procedure, 337 - >( 338 - method: M, 339 - configOrFn: LexMethodConfig<M, void> | LexMethodHandler<M, void>, 340 - ): void; 341 - method< 342 - M extends Query | Procedure, 343 - A extends Auth, 344 - >( 345 - method: M, 346 - configOrFn: LexMethodConfig<M, A> | LexMethodHandler<M, A>, 347 - ): void; 348 - method< 349 - A extends AuthResult, 350 - P extends Params = Params, 351 - I extends Input = Input, 352 - O extends Output = Output, 353 - >( 354 - nsid: string, 355 - configOrFn: MethodConfigWithAuth<A, P, I, O>, 356 - ): void; 357 - method< 358 - A extends Auth, 359 - P extends Params = Params, 360 - I extends Input = Input, 361 - O extends Output = Output, 362 - >( 363 - nsid: string, 364 - configOrFn: MethodConfigOrHandler<A, P, I, O>, 365 - ): void; 366 197 method( 367 - nsidOrMethod: string | Query | Procedure, 368 - configOrFn: unknown, 369 - ): void { 370 - if (typeof nsidOrMethod === "string") { 371 - this.addMethod(nsidOrMethod, configOrFn as MethodConfigOrHandler); 372 - return; 373 - } 374 - this.addMethod(nsidOrMethod as any, configOrFn as any); 198 + nsid: string, 199 + configOrFn: MethodConfigOrHandler, 200 + ) { 201 + this.addMethod(nsid, configOrFn); 375 202 } 376 203 377 204 /** ··· 380 207 * @param configOrFn - Either a handler function or full method configuration 381 208 * @throws {Error} If the method is not found in the lexicon or is not a query/procedure 382 209 */ 383 - addMethod< 384 - M extends Query | Procedure, 385 - A extends AuthResult, 386 - >( 387 - method: M, 388 - configOrFn: LexMethodConfigWithAuth<M, A>, 389 - ): void; 390 - addMethod< 391 - M extends Query | Procedure, 392 - >( 393 - method: M, 394 - configOrFn: LexMethodConfig<M, void> | LexMethodHandler<M, void>, 395 - ): void; 396 - addMethod< 397 - M extends Query | Procedure, 398 - A extends Auth, 399 - >( 400 - method: M, 401 - configOrFn: LexMethodConfig<M, A> | LexMethodHandler<M, A>, 402 - ): void; 403 - addMethod< 404 - A extends AuthResult, 405 - P extends Params = Params, 406 - I extends Input = Input, 407 - O extends Output = Output, 408 - >( 409 - nsid: string, 410 - configOrFn: MethodConfigWithAuth<A, P, I, O>, 411 - ): void; 412 - addMethod< 413 - A extends Auth, 414 - P extends Params = Params, 415 - I extends Input = Input, 416 - O extends Output = Output, 417 - >( 418 - nsid: string, 419 - configOrFn: MethodConfigOrHandler<A, P, I, O>, 420 - ): void; 421 210 addMethod( 422 - nsidOrMethod: string | Query | Procedure, 423 - configOrFn: unknown, 424 - ): void { 425 - const config: MethodConfig = typeof configOrFn === "function" 426 - ? { handler: configOrFn as MethodConfig["handler"] } 427 - : configOrFn as MethodConfig; 428 - 429 - if (typeof nsidOrMethod !== "string") { 430 - this.addLexMethod(nsidOrMethod, config); 431 - return; 432 - } 433 - 434 - const def = this.lex.getDef(nsidOrMethod); 211 + nsid: string, 212 + configOrFn: MethodConfigOrHandler, 213 + ) { 214 + const config = typeof configOrFn === "function" 215 + ? { handler: configOrFn } 216 + : configOrFn; 217 + const def = this.lex.getDef(nsid); 435 218 if (!def || (def.type !== "query" && def.type !== "procedure")) { 436 - throw new Error(`Method not found in lexicon: ${nsidOrMethod}`); 219 + throw new Error(`Method not found in lexicon: ${nsid}`); 437 220 } 438 - this.addRoute(nsidOrMethod, def, config); 221 + this.addRoute(nsid, def, config); 439 222 } 440 223 441 224 /** ··· 443 226 * @param nsid - The namespace identifier for the streaming method 444 227 * @param configOrFn - Either a stream handler function or full stream configuration 445 228 */ 446 - streamMethod< 447 - M extends Subscription, 448 - A extends AuthResult, 449 - >( 450 - method: M, 451 - configOrFn: LexSubscriptionConfigWithAuth<M, A>, 452 - ): void; 453 - streamMethod< 454 - M extends Subscription, 455 - >( 456 - method: M, 457 - configOrFn: 458 - | LexSubscriptionConfig<M, void> 459 - | LexSubscriptionHandler<M, void>, 460 - ): void; 461 - streamMethod< 462 - M extends Subscription, 463 - A extends Auth, 464 - >( 465 - method: M, 466 - configOrFn: LexSubscriptionConfig<M, A> | LexSubscriptionHandler<M, A>, 467 - ): void; 468 - streamMethod< 469 - A extends AuthResult, 470 - P extends Params = Params, 471 - O = unknown, 472 - >( 229 + streamMethod( 473 230 nsid: string, 474 - configOrFn: StreamConfigWithAuth<A, P, O>, 475 - ): void; 476 - streamMethod< 477 - A extends Auth, 478 - P extends Params = Params, 479 - O = unknown, 480 - >( 481 - nsid: string, 482 - configOrFn: StreamConfigOrHandler<A, P, O>, 483 - ): void; 484 - streamMethod( 485 - nsidOrMethod: string | Subscription, 486 - configOrFn: unknown, 487 - ): void { 488 - if (typeof nsidOrMethod === "string") { 489 - this.addStreamMethod(nsidOrMethod, configOrFn as StreamConfigOrHandler); 490 - return; 491 - } 492 - this.addStreamMethod(nsidOrMethod as any, configOrFn as any); 231 + configOrFn: StreamConfigOrHandler, 232 + ) { 233 + this.addStreamMethod(nsid, configOrFn); 493 234 } 494 235 495 236 /** ··· 498 239 * @param configOrFn - Either a stream handler function or full stream configuration 499 240 * @throws {Error} If the subscription is not found in the lexicon 500 241 */ 501 - addStreamMethod< 502 - M extends Subscription, 503 - A extends AuthResult, 504 - >( 505 - method: M, 506 - configOrFn: LexSubscriptionConfigWithAuth<M, A>, 507 - ): void; 508 - addStreamMethod< 509 - M extends Subscription, 510 - >( 511 - method: M, 512 - configOrFn: 513 - | LexSubscriptionConfig<M, void> 514 - | LexSubscriptionHandler<M, void>, 515 - ): void; 516 - addStreamMethod< 517 - M extends Subscription, 518 - A extends Auth, 519 - >( 520 - method: M, 521 - configOrFn: LexSubscriptionConfig<M, A> | LexSubscriptionHandler<M, A>, 522 - ): void; 523 - addStreamMethod< 524 - A extends AuthResult, 525 - P extends Params = Params, 526 - O = unknown, 527 - >( 528 - nsid: string, 529 - configOrFn: StreamConfigWithAuth<A, P, O>, 530 - ): void; 531 - addStreamMethod< 532 - A extends Auth, 533 - P extends Params = Params, 534 - O = unknown, 535 - >( 536 - nsid: string, 537 - configOrFn: StreamConfigOrHandler<A, P, O>, 538 - ): void; 539 242 addStreamMethod( 540 - nsidOrMethod: string | Subscription, 541 - configOrFn: unknown, 542 - ): void { 543 - const config: StreamConfig = typeof configOrFn === "function" 544 - ? { handler: configOrFn as StreamConfig["handler"] } 545 - : configOrFn as StreamConfig; 546 - 547 - if (typeof nsidOrMethod !== "string") { 548 - this.addLexSubscription(nsidOrMethod, config); 549 - return; 550 - } 551 - 552 - const def = this.lex.getDef(nsidOrMethod); 243 + nsid: string, 244 + configOrFn: StreamConfigOrHandler, 245 + ) { 246 + const config = typeof configOrFn === "function" 247 + ? { handler: configOrFn } 248 + : configOrFn; 249 + const def = this.lex.getDef(nsid); 553 250 if (!def || def.type !== "subscription") { 554 - throw new Error(`Subscription not found in lexicon: ${nsidOrMethod}`); 251 + throw new Error(`Subscription not found in lexicon: ${nsid}`); 555 252 } 556 - this.addSubscription(nsidOrMethod, def, config); 253 + this.addSubscription(nsid, def, config); 557 254 } 558 255 559 256 // lexicon ··· 598 295 } else { 599 296 this.app.get(path, handler); 600 297 } 601 - 602 - this.handlers.set(nsid, this.fetch); 603 - } 604 - 605 - protected addLexMethod( 606 - method: Query | Procedure, 607 - config: MethodConfig, 608 - ) { 609 - const nsid = method.nsid; 610 - const path = `/xrpc/${nsid}`; 611 - const handler = this.createLexHandler(method, config); 612 - this.methods.set(nsid, method); 613 - 614 - if (method instanceof Procedure) { 615 - this.app.post(path, handler); 616 - } else { 617 - this.app.get(path, handler); 618 - } 619 - 620 - this.handlers.set(nsid, this.fetch); 621 298 } 622 299 623 300 /** 624 301 * Catchall handler that processes all XRPC routes and applies global rate limiting. 625 302 */ 626 303 catchall: CatchallHandler = async (c, next) => { 627 - const pathname = new URL(c.req.url).pathname; 628 - if (!pathname.startsWith("/xrpc/")) { 304 + if (!c.req.url.includes("/xrpc/")) { 629 305 return await next(); 630 306 } 631 - if (pathname === "/xrpc/_health") return await next(); 632 307 633 308 // Validate the NSID 634 309 const nsid = parseUrlNsid(c.req.url); ··· 655 330 656 331 // Ensure that known XRPC methods are only called with the correct HTTP 657 332 // method. 658 - const def = this.getMethodDefinition(nsid); 333 + const def = this.lex.getDef(nsid); 659 334 if (def) { 660 335 const expectedMethod = def.type === "procedure" 661 336 ? "POST" ··· 713 388 routeOpts: RouteOptions, 714 389 ): (req: Request) => Awaitable<Input> { 715 390 return createInputVerifier(nsid, def, routeOpts, this.lex); 716 - } 717 - 718 - protected createLexInputVerifier( 719 - method: Query | Procedure, 720 - routeOpts: RouteOptions, 721 - ): (req: Request) => Awaitable<Input> { 722 - if (method instanceof Query) { 723 - return createInputVerifier( 724 - method.nsid, 725 - { type: "query" } as LexXrpcQuery, 726 - routeOpts, 727 - this.lex, 728 - ); 729 - } 730 - 731 - return createInputVerifier( 732 - method.nsid, 733 - { 734 - type: "procedure", 735 - input: method.input.encoding 736 - ? { encoding: method.input.encoding } 737 - : undefined, 738 - } as LexXrpcProcedure, 739 - routeOpts, 740 - this.lex, 741 - ); 742 - } 743 - 744 - protected createLexParamsVerifier( 745 - method: Query | Procedure | Subscription, 746 - ): (req: Request) => Params { 747 - return (req: Request): Params => { 748 - try { 749 - const { searchParams } = new URL(req.url); 750 - return method.parameters.fromURLSearchParams(searchParams) as Params; 751 - } catch (e) { 752 - throw new InvalidRequestError(String(e)); 753 - } 754 - }; 755 391 } 756 392 757 393 /** ··· 870 506 }; 871 507 } 872 508 873 - createLexHandler<A extends Auth = Auth>( 874 - method: Query | Procedure, 875 - cfg: MethodConfig<A>, 876 - ): Handler { 877 - const authVerifier = this.createAuthVerifier(cfg); 878 - const paramsVerifier = this.createLexParamsVerifier(method); 879 - const inputVerifier = this.createLexInputVerifier(method, { 880 - blobLimit: cfg.opts?.blobLimit ?? this.options.payload?.blobLimit, 881 - jsonLimit: cfg.opts?.jsonLimit ?? this.options.payload?.jsonLimit, 882 - textLimit: cfg.opts?.textLimit ?? this.options.payload?.textLimit, 883 - }); 884 - const validateOutputFn = (output?: HandlerSuccess) => 885 - this.options.validateResponse && output 886 - ? this.validateLexOutput(method, output) 887 - : undefined; 888 - 889 - const routeLimiter = this.createRouteRateLimiter(method.nsid, cfg); 890 - 891 - return async (c: Context) => { 892 - try { 893 - const params = paramsVerifier(c.req.raw); 894 - 895 - const auth: A = authVerifier 896 - ? await authVerifier({ req: c.req.raw, res: c.res, params }) 897 - : (undefined as A); 898 - 899 - let input: Input = undefined; 900 - if (method instanceof Procedure) { 901 - input = await inputVerifier(c.req.raw); 902 - if (input && method.input.schema) { 903 - const result = method.input.schema.safeParse(input.body); 904 - if (!result.success) { 905 - throw new InvalidRequestError(result.error.message); 906 - } 907 - input = { ...input, body: result.value }; 908 - } 909 - } 910 - 911 - const ctx: HandlerContext<A> = { 912 - req: c.req.raw, 913 - res: new Response(), 914 - params, 915 - input, 916 - auth: auth as A, 917 - resetRouteRateLimits: async () => { 918 - if (routeLimiter) { 919 - await routeLimiter.reset(ctx); 920 - } 921 - }, 922 - }; 923 - 924 - if (routeLimiter) { 925 - await routeLimiter.handle(ctx); 926 - } 927 - 928 - const output = await cfg.handler(ctx); 929 - if (isErrorResult(output)) { 930 - throw XRPCError.fromErrorResult(output); 931 - } 932 - 933 - if (isHandlerPipeThroughBuffer(output)) { 934 - setHeaders(c, output.headers); 935 - return c.body(output.buffer.buffer as ArrayBuffer, 200, { 936 - "Content-Type": output.encoding, 937 - }); 938 - } else if (isHandlerPipeThroughStream(output)) { 939 - setHeaders(c, output.headers); 940 - return c.body(output.stream, 200, { 941 - "Content-Type": output.encoding, 942 - }); 943 - } 944 - 945 - if (output) { 946 - excludeErrorResult(output); 947 - validateOutputFn(output); 948 - } 949 - 950 - if (output) { 951 - setHeaders(c, output.headers); 952 - if (output.encoding === "application/json") { 953 - return c.json(ipldToJson(output.body) as JSON); 954 - } else { 955 - return c.body(output.body, 200, { 956 - "Content-Type": output.encoding, 957 - }); 958 - } 959 - } 960 - 961 - return c.body(null, 200); 962 - } catch (err: unknown) { 963 - throw err || new InternalServerError(); 964 - } 965 - }; 966 - } 967 - 968 509 /** 969 510 * Adds a WebSocket subscription handler for the specified NSID. 970 511 * @param nsid - The namespace identifier for the subscription ··· 1026 567 }, 1027 568 }), 1028 569 ); 1029 - this.handlers.set(nsid, this.fetch); 1030 - } 1031 - 1032 - protected addLexSubscription<A extends Auth = Auth>( 1033 - method: Subscription, 1034 - cfg: StreamConfig<A>, 1035 - ) { 1036 - const paramsVerifier = this.createLexParamsVerifier(method); 1037 - const authVerifier = this.createAuthVerifier(cfg); 1038 - const nsid = method.nsid; 1039 - const { handler } = cfg; 1040 - this.streamMethods.set(nsid, method); 1041 - 1042 - this.subscriptions.set( 1043 - nsid, 1044 - new XrpcStreamServer({ 1045 - handler: async function* (req, signal) { 1046 - try { 1047 - const params = paramsVerifier(req); 1048 - const auth = authVerifier 1049 - ? await authVerifier({ req, params }) 1050 - : (undefined as A); 1051 - 1052 - for await (const item of handler({ req, params, auth, signal })) { 1053 - if (item instanceof Frame) { 1054 - yield item; 1055 - continue; 1056 - } 1057 - const type = (item as Record<string, unknown>)?.["$type"]; 1058 - if (!check.is(item, schema.map) || typeof type !== "string") { 1059 - yield new MessageFrame(item); 1060 - continue; 1061 - } 1062 - const split = type.split("#"); 1063 - let t: string; 1064 - if ( 1065 - split.length === 2 && (split[0] === "" || split[0] === nsid) 1066 - ) { 1067 - t = `#${split[1]}`; 1068 - } else { 1069 - t = type; 1070 - } 1071 - const clone = { ...(item as Record<string, unknown>) }; 1072 - delete clone["$type"]; 1073 - yield new MessageFrame(clone, { type: t }); 1074 - } 1075 - } catch (err) { 1076 - const xrpcError = XRPCError.fromError(err); 1077 - yield new ErrorFrame({ 1078 - error: xrpcError.payload.error ?? "Unknown", 1079 - message: xrpcError.payload.message, 1080 - }); 1081 - } 1082 - }, 1083 - }), 1084 - ); 1085 - this.handlers.set(nsid, this.fetch); 1086 - } 1087 - 1088 - protected validateLexOutput( 1089 - method: Query | Procedure, 1090 - output: HandlerSuccess | void, 1091 - ) { 1092 - const expected = method.output.encoding; 1093 - 1094 - if (expected === undefined) { 1095 - if (output !== undefined) { 1096 - throw new InternalServerError( 1097 - "A response body was provided when none was expected", 1098 - ); 1099 - } 1100 - return; 1101 - } 1102 - 1103 - if (output === undefined) { 1104 - throw new InternalServerError( 1105 - "A response body is expected but none was provided", 1106 - ); 1107 - } 1108 - 1109 - if (!matchesEncoding(expected, output.encoding)) { 1110 - throw new InternalServerError( 1111 - `Invalid response encoding: ${output.encoding}`, 1112 - ); 1113 - } 1114 - 1115 - if (method.output.schema) { 1116 - const result = method.output.schema.safeParse(output.body); 1117 - if (!result.success) { 1118 - throw new InternalServerError(result.error.message); 1119 - } 1120 - output.body = result.value; 1121 - } 1122 - } 1123 - 1124 - private getMethodDefinition( 1125 - nsid: string, 1126 - ): undefined | { type: "query" | "procedure" | "subscription" } { 1127 - const method = this.methods.get(nsid); 1128 - if (method) { 1129 - return method instanceof Procedure 1130 - ? { type: "procedure" } 1131 - : { type: "query" }; 1132 - } 1133 - 1134 - if (this.streamMethods.has(nsid)) { 1135 - return { type: "subscription" }; 1136 - } 1137 - 1138 - const def = this.lex.getDef(nsid); 1139 - if ( 1140 - def && 1141 - (def.type === "query" || def.type === "procedure" || 1142 - def.type === "subscription") 1143 - ) { 1144 - return { type: def.type }; 1145 - } 1146 - 1147 - return undefined; 1148 570 } 1149 571 1150 572 private createRouteRateLimiter<A extends Auth, C extends HandlerContext>( ··· 1209 631 ); 1210 632 } 1211 633 1212 - fetch: FetchHandler = async (request: Request): Promise<Response> => { 1213 - return await this.handler.fetch(request); 1214 - }; 1215 - 1216 634 /** 1217 635 * Gets the underlying Hono app instance for external use. 1218 636 * @returns The Hono application instance ··· 1224 642 1225 643 function createErrorHandler( 1226 644 opts: Options, 1227 - ): (err: Error, c: Context) => Promise<Response> { 1228 - return async (err: Error, c: Context): Promise<Response> => { 645 + ): (err: Error, c: Context) => Response { 646 + return (err: Error, c: Context): Response => { 1229 647 const errorParser = opts.errorParser || 1230 648 ((e: unknown) => XRPCError.fromError(e)); 1231 649 const xrpcError = errorParser(err); 1232 - const nsid = parseUrlNsid(c.req.url) ?? undefined; 1233 - 1234 - if (opts.onHandlerError) { 1235 - await opts.onHandlerError({ 1236 - error: xrpcError, 1237 - request: c.req.raw, 1238 - nsid, 1239 - }); 1240 - } 1241 650 1242 651 const statusCode = "statusCode" in xrpcError 1243 652 ? (xrpcError as { statusCode: number }).statusCode ··· 1267 676 "unknown"; 1268 677 return ip; 1269 678 }; 1270 - 1271 - function matchesEncoding(expected: string, actual: string): boolean { 1272 - const normalizedExpected = normalizeEncoding(expected); 1273 - const normalizedActual = normalizeEncoding(actual); 1274 - 1275 - if (normalizedExpected === "*/*") { 1276 - return true; 1277 - } 1278 - 1279 - const [expectedType, expectedSubtype] = normalizedExpected.split("/"); 1280 - const [actualType, actualSubtype] = normalizedActual.split("/"); 1281 - 1282 - if ( 1283 - expectedType == null || 1284 - expectedSubtype == null || 1285 - actualType == null || 1286 - actualSubtype == null 1287 - ) { 1288 - return false; 1289 - } 1290 - 1291 - if (expectedType !== "*" && expectedType !== actualType) { 1292 - return false; 1293 - } 1294 - 1295 - if (expectedSubtype !== "*" && expectedSubtype !== actualSubtype) { 1296 - return false; 1297 - } 1298 - 1299 - return true; 1300 - } 1301 - 1302 - function normalizeEncoding(encoding: string): string { 1303 - return encoding.split(";", 1)[0]?.trim().toLowerCase() ?? ""; 1304 - }
-205
xrpc-server/tests/lex-router-api_test.ts
··· 1 - import { l } from "@atp/lex"; 2 - import * as xrpcServer from "../mod.ts"; 3 - import { assertEquals, assertRejects } from "@std/assert"; 4 - 5 - type IsAny<T> = 0 extends (1 & T) ? true : false; 6 - type IsEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends 7 - (<T>() => T extends B ? 1 : 2) ? true : false; 8 - type Assert<T extends true> = T; 9 - 10 - Deno.test("Server.add registers lex method handlers and exposes fetch/handlers", async () => { 11 - const server = new xrpcServer.Server(); 12 - const method = l.query( 13 - "io.example.lexRouterApi", 14 - l.params({ value: l.string() }), 15 - l.jsonPayload({ value: l.string() }), 16 - ); 17 - 18 - const returned = server.add(method, (ctx) => ({ 19 - encoding: "application/json", 20 - body: { value: String(ctx.params.value) }, 21 - })); 22 - 23 - assertEquals(returned, server); 24 - assertEquals(server.handlers.has(method.nsid), true); 25 - 26 - const response = await server.fetch( 27 - new Request("http://localhost/xrpc/io.example.lexRouterApi?value=hello"), 28 - ); 29 - assertEquals(response.status, 200); 30 - assertEquals(await response.json(), { value: "hello" }); 31 - 32 - await assertRejects( 33 - async () => { 34 - server.add(method, () => ({ 35 - encoding: "application/json", 36 - body: { value: "duplicate" }, 37 - })); 38 - }, 39 - TypeError, 40 - `Method ${method.nsid} already registered`, 41 - ); 42 - }); 43 - 44 - Deno.test("Server.add accepts namespace objects with main", async () => { 45 - const server = new xrpcServer.Server(); 46 - const namespace = { 47 - main: l.query( 48 - "io.example.lexRouterNamespace", 49 - l.params({ value: l.string() }), 50 - l.jsonPayload({ value: l.string() }), 51 - ), 52 - }; 53 - 54 - server.add(namespace, (ctx) => ({ 55 - encoding: "application/json", 56 - body: { value: String(ctx.params.value) }, 57 - })); 58 - 59 - const response = await server.fetch( 60 - new Request("http://localhost/xrpc/io.example.lexRouterNamespace?value=ok"), 61 - ); 62 - assertEquals(response.status, 200); 63 - assertEquals(await response.json(), { value: "ok" }); 64 - }); 65 - 66 - Deno.test("Server.add infers params type from lex methods", () => { 67 - const server = new xrpcServer.Server(); 68 - const query = l.query( 69 - "io.example.paramsInference", 70 - l.params({ value: l.string() }), 71 - l.jsonPayload({ ok: l.boolean() }), 72 - ); 73 - 74 - server.add(query, { 75 - handler: (ctx) => { 76 - type Value = typeof ctx.params.value; 77 - type _isNotAny = Assert<IsEqual<IsAny<Value>, false>>; 78 - const value: string = ctx.params.value; 79 - return { 80 - encoding: "application/json", 81 - body: { ok: value.length > 0 }, 82 - }; 83 - }, 84 - }); 85 - }); 86 - 87 - Deno.test("Server supports LexRouter-style healthCheck and fallback options", async () => { 88 - const server = xrpcServer.createServer({ 89 - healthCheck: async () => ({ status: "ok", service: "xrpc-server" }), 90 - fallback: async () => new Response("fallback", { status: 418 }), 91 - }); 92 - 93 - const healthResponse = await server.fetch( 94 - new Request("http://localhost/xrpc/_health"), 95 - ); 96 - assertEquals(healthResponse.status, 200); 97 - assertEquals(await healthResponse.json(), { 98 - status: "ok", 99 - service: "xrpc-server", 100 - }); 101 - 102 - const fallbackResponse = await server.fetch( 103 - new Request("http://localhost/anything"), 104 - ); 105 - assertEquals(fallbackResponse.status, 418); 106 - assertEquals(await fallbackResponse.text(), "fallback"); 107 - }); 108 - 109 - Deno.test("Server.add infers auth credentials type in handler", () => { 110 - const server = new xrpcServer.Server(); 111 - const method = l.query( 112 - "io.example.authInference", 113 - l.params(), 114 - l.jsonPayload({ ok: l.boolean() }), 115 - ); 116 - 117 - server.add(method, { 118 - auth: () => ({ 119 - credentials: { 120 - userId: "u1", 121 - }, 122 - }), 123 - handler: (ctx) => { 124 - const userId: string = ctx.auth.credentials.userId; 125 - return { 126 - encoding: "application/json", 127 - body: { ok: userId.length > 0 }, 128 - }; 129 - }, 130 - }); 131 - }); 132 - 133 - Deno.test( 134 - "Server.add infers auth type from callable verifier methods", 135 - () => { 136 - const server = new xrpcServer.Server(); 137 - 138 - type StandardOutput = { 139 - credentials: { 140 - type: "standard"; 141 - aud: string; 142 - iss: string; 143 - }; 144 - artifacts: unknown; 145 - }; 146 - 147 - type RoleOutput = { 148 - credentials: { 149 - type: "role"; 150 - admin: boolean; 151 - }; 152 - artifacts: unknown; 153 - }; 154 - 155 - interface ExtendedAuthVerifier { 156 - standardOrRole: ( 157 - ctx: xrpcServer.MethodAuthContext, 158 - ) => Promise<StandardOutput | RoleOutput>; 159 - } 160 - 161 - interface AuthVerifier extends ExtendedAuthVerifier { 162 - (ctx: xrpcServer.MethodAuthContext): Promise<xrpcServer.AuthResult>; 163 - } 164 - 165 - const authVerifier = ((_: xrpcServer.MethodAuthContext) => 166 - Promise.resolve({ 167 - credentials: { type: "none" }, 168 - })) as AuthVerifier; 169 - 170 - authVerifier.standardOrRole = async () => ({ 171 - credentials: { type: "role", admin: true }, 172 - artifacts: null, 173 - }); 174 - 175 - const query = l.query( 176 - "io.example.authInferenceCallable", 177 - l.params(), 178 - l.jsonPayload({ ok: l.boolean() }), 179 - ); 180 - 181 - server.add(query, { 182 - auth: authVerifier.standardOrRole, 183 - handler: (ctx) => { 184 - type InferredAuth = typeof ctx.auth; 185 - type _isNotAny = Assert<IsEqual<IsAny<InferredAuth>, false>>; 186 - type _isCorrect = Assert< 187 - IsEqual<InferredAuth, StandardOutput | RoleOutput> 188 - >; 189 - const variant = ctx.auth.credentials.type; 190 - if (variant === "role") { 191 - const admin: boolean = ctx.auth.credentials.admin; 192 - return { 193 - encoding: "application/json", 194 - body: { ok: admin }, 195 - }; 196 - } 197 - const aud: string = ctx.auth.credentials.aud; 198 - return { 199 - encoding: "application/json", 200 - body: { ok: aud.length > 0 }, 201 - }; 202 - }, 203 - }); 204 - }, 205 - );
+1 -104
xrpc-server/types.ts
··· 1 1 import type { Context, HonoRequest, Next } from "hono"; 2 2 import { z } from "zod"; 3 - import type { 4 - InferMethodParams, 5 - Procedure, 6 - Query, 7 - Subscription, 8 - } from "@atp/lex"; 9 3 import type { ErrorResult, XRPCError } from "./errors.ts"; 10 4 import type { CalcKeyFn, CalcPointsFn } from "./rate-limiter.ts"; 11 5 import type { RateLimiterI } from "./rate-limiter.ts"; ··· 27 21 next: Next, 28 22 ) => Promise<void | Response>; 29 23 30 - export type FetchHandler = ( 31 - request: Request, 32 - connection?: unknown, 33 - ) => Awaitable<Response>; 34 - 35 - export type HealthCheckHandler = ( 36 - request: Request, 37 - ) => Awaitable<{ [x: string]: unknown; status: "ok" }>; 38 - 39 - export type HandlerErrorHook = (ctx: { 40 - error: XRPCError; 41 - request: Request; 42 - nsid?: string; 43 - }) => Awaitable<void>; 44 - 45 - export type SocketErrorHook = (ctx: { 46 - error: unknown; 47 - request: Request; 48 - nsid?: string; 49 - }) => Awaitable<void>; 50 - 51 24 /** 52 25 * Configuration options for the XRPC server. 53 26 */ 54 27 export type Options = { 55 28 /** Whether to validate response schemas */ 56 29 validateResponse?: boolean; 57 - /** Optional fallback handler for non-/xrpc/* requests */ 58 - fallback?: FetchHandler; 59 - /** Optional health check handler for /xrpc/_health */ 60 - healthCheck?: HealthCheckHandler; 61 - /** Optional callback for reporting handler errors */ 62 - onHandlerError?: HandlerErrorHook; 63 - /** Optional callback for reporting socket errors */ 64 - onSocketError?: SocketErrorHook; 65 - /** Optional high water mark for websocket buffering */ 66 - highWaterMark?: number; 67 - /** Optional low water mark for websocket buffering */ 68 - lowWaterMark?: number; 69 - /** Optional websocket upgrade function (reserved for API parity) */ 70 - upgradeWebSocket?: unknown; 71 30 /** Handler for catching all unmatched routes */ 72 31 catchall?: CatchallHandler; 73 32 /** Payload size limits for different content types */ ··· 404 363 blobLimit?: number; 405 364 }; 406 365 407 - export type MethodAuth< 408 - A extends Auth = Auth, 409 - P extends Params = Params, 410 - > = MethodAuthVerifier<Extract<A, AuthResult>, P>; 411 - 412 366 /** 413 367 * Configuration object for an XRPC method including handler, auth, and options. 414 368 * @template A - Authentication type ··· 425 379 /** The method handler function */ 426 380 handler: MethodHandler<A, P, I, O>; 427 381 /** Optional authentication verifier */ 428 - auth?: MethodAuth<A, P>; 382 + auth?: MethodAuthVerifier<Extract<A, AuthResult>, P>; 429 383 /** Optional route configuration */ 430 384 opts?: RouteOptions; 431 385 /** Optional rate limiting configuration */ ··· 434 388 | RateLimitOpts<HandlerContext<A, P, I>>[]; 435 389 }; 436 390 437 - export type MethodConfigWithAuth< 438 - A extends AuthResult = AuthResult, 439 - P extends Params = Params, 440 - I extends Input = Input, 441 - O extends Output = Output, 442 - > = { 443 - handler: MethodHandler<A, P, I, O>; 444 - auth: MethodAuth<A, P>; 445 - opts?: RouteOptions; 446 - rateLimit?: 447 - | RateLimitOpts<HandlerContext<A, P, I>> 448 - | RateLimitOpts<HandlerContext<A, P, I>>[]; 449 - }; 450 - 451 391 /** 452 392 * Union type allowing either a simple handler function or full method configuration. 453 393 * @template A - Authentication type ··· 462 402 O extends Output = Output, 463 403 > = MethodHandler<A, P, I, O> | MethodConfig<A, P, I, O>; 464 404 465 - export type LexMethodParams< 466 - M extends Procedure | Query | Subscription, 467 - > = InferMethodParams<M>; 468 - 469 - export type LexMethodHandler< 470 - M extends Procedure | Query, 471 - A extends Auth = Auth, 472 - > = MethodHandler<A, LexMethodParams<M>, Input, Output>; 473 - 474 - export type LexMethodConfig< 475 - M extends Procedure | Query, 476 - A extends Auth = Auth, 477 - > = MethodConfig<A, LexMethodParams<M>, Input, Output>; 478 - 479 - export type LexMethodConfigWithAuth< 480 - M extends Procedure | Query, 481 - A extends AuthResult = AuthResult, 482 - > = MethodConfigWithAuth<A, LexMethodParams<M>, Input, Output>; 483 - 484 405 /** 485 406 * Configuration object for a streaming XRPC endpoint. 486 407 * @template A - Authentication type ··· 497 418 /** The stream handler function */ 498 419 handler: StreamHandler<A, P, O>; 499 420 }; 500 - 501 - export type StreamConfigWithAuth< 502 - A extends AuthResult = AuthResult, 503 - P extends Params = Params, 504 - O = unknown, 505 - > = { 506 - auth: StreamAuthVerifier<A, P>; 507 - handler: StreamHandler<A, P, O>; 508 - }; 509 - 510 - export type LexSubscriptionHandler< 511 - M extends Subscription, 512 - A extends Auth = Auth, 513 - > = StreamHandler<A, LexMethodParams<M>, unknown>; 514 - 515 - export type LexSubscriptionConfig< 516 - M extends Subscription, 517 - A extends Auth = Auth, 518 - > = StreamConfig<A, LexMethodParams<M>, unknown>; 519 - 520 - export type LexSubscriptionConfigWithAuth< 521 - M extends Subscription, 522 - A extends AuthResult = AuthResult, 523 - > = StreamConfigWithAuth<A, LexMethodParams<M>, unknown>; 524 421 525 422 /** 526 423 * Union type allowing either a simple stream handler or full stream configuration.